What Is Unit Testing? (CS Best Practices)
Unit testing checks a small piece of software, such as a function or class, against its expected behavior. A good unit test is focused, repeatable, and clear about what it verifies. Unit tests help find regressions early, but they do not prove a whole program works or replace integration and system testing.
As software projects grow, a small code change can affect behavior far from the place where it was made. Unit tests help developers check important rules quickly, often before they combine their work with the rest of an application. Yet writing more tests does not automatically mean better testing. The value comes from choosing meaningful cases and checking real outcomes.
In this guide, I’ll explain what counts as a unit, how to design test cases, and how to use results such as coverage without misreading them. The examples are teaching scenarios, not reports of a particular company’s results. They show how to turn a requirement into a test you can run and understand.
Unit Testing Overview: Purpose, Scope, and Testing Frameworks
A unit test checks whether a small part of a program behaves as specified. A unit might be a function, method, or class, depending on the language and design. The test supplies known conditions, runs the unit, and checks an observable result, such as a return value or expected error.
The purpose is to catch defects close to where they occur. Suppose a function calculates a discount. A test can check the price after a discount, the result when the discount is zero, and what happens when the input is invalid. If one result changes after a code edit, the test can flag a possible regression.
A unit test differs from broader tests by its scope. An integration test checks whether parts work together, such as an application and its database. A system test checks a complete system against broader requirements. Acceptance testing checks whether the product meets user or business needs. These test types answer different questions; unit tests are not replacements for them.
A testing framework provides tools to run tests and report results. Common examples include JUnit for Java, pytest for Python, and Jest or Vitest for JavaScript and TypeScript. Use the framework approved for your course or project, and follow its setup instructions. Names and commands can vary by project.
| Test type | Main question | Example |
|---|---|---|
| Unit | Does this small component follow its rule? | Does a discount function return the right price? |
| Integration | Do connected parts work together? | Can a service save and retrieve a record? |
| System | Does the whole application behave as required? | Can a user complete a full workflow? |
| Acceptance | Does the solution meet agreed needs? | Does the workflow meet a stated user requirement? |
Key takeaway: Start with the behavior of one small unit, but plan for other test types to check the larger system.
Designing Test Cases: Behaviors, Boundaries, and Dependencies
A test case is a specific set of conditions and an expected result. Good cases come from requirements, not from a desire to exercise every line. First identify the unit’s inputs, outputs, preconditions, and error behavior. Then choose cases that show whether those rules hold.
A useful pattern is Arrange–Act–Assert. Arrange means prepare inputs and any needed conditions. Act means call the unit. Assert means check the result. Many frameworks do not require these exact labels, but the pattern can make a test easier to read.
For example, imagine a function that calculates the total cost after applying a percentage discount. The requirement says the discount must be between zero and 100. A test can arrange a price of 80 and a discount of 25, call the function, then assert that the result is 60. A separate test should check how the function handles a discount outside the allowed range, based on the project’s stated rule.
Which cases should a unit test cover?
A strong test set covers distinct behavior, not just many inputs. Normal cases check expected use. Boundary cases check values at the edge of a rule. Failure cases check what happens when inputs are missing or invalid. The exact cases depend on the unit’s requirements, so do not assume every function needs the same checklist.
For a number allowed from 0 through 100, useful cases might include 0, a value inside the range, 100, and a value outside it. Testing only 50 would miss possible errors at the limits. If the function handles an empty list, a very large value, or a special state, those may also need tests when the requirement makes them relevant.
| Case | Example input | What the test checks |
|---|---|---|
| Typical valid input | 25% discount on 80 | Expected calculation |
| Lower boundary | 0% discount | Boundary rule |
| Upper boundary | 100% discount | Boundary rule |
| Invalid input | 120% discount | Expected error or rejection |
| Repeated call | Same input twice | Consistent result, if required |
How should tests handle dependencies?
A dependency is another component a unit relies on, such as a clock, database, network service, or file system. Isolation means controlling those dependencies when needed so a test can focus on the unit’s behavior. A stub supplies a planned response; a mock can also record whether an interaction took place.
Mocks and stubs are tools, not requirements for every test. Use them when a real dependency would make a test slow, unreliable, costly, or hard to control. For example, a test of a function that formats a date may use a fixed date instead of the current time. But if a real dependency is simple and stable, adding a mock can make the test harder to understand.
Avoid making a test depend on uncontrolled network access, random values, current time, or shared mutable state. These can make the same test pass once and fail later without a relevant code change. When randomness is part of the behavior, use a controlled seed or inject a predictable source if the design allows it.
Next step: Write down the rule each test checks. If you cannot say what a failure would mean, revise the test.
Academic Success: Test Reliability, Debugging, and Continuous Integration
A reliable test gives the same result when the code and test conditions have not changed. A flaky test passes and fails unpredictably, often because it relies on timing, shared state, or an unstable service. Reliable tests save debugging time because their failures point more clearly to a real change or defect.
Start by running the project’s documented test command. When a test fails, read the failure message and identify the specific assertion that failed. Then check whether the expected result, test setup, or code behavior conflicts with the requirement. Do not change an assertion just to make the test pass; first decide which result is correct.
When one test fails, rerun it in the project’s supported way, then run the full relevant suite. If it fails only in a group, look for shared state or test-order dependence. If it fails intermittently, inspect time, randomness, asynchronous work, and network or file access. Keep test data isolated where practical.
Continuous integration, or CI, is an automated process that runs checks when code changes are submitted. A project may use CI to run unit tests before merging a change. This makes test results visible to the team, but CI does not make a weak test meaningful. A test that checks the wrong outcome will still check the wrong outcome automatically.
What does code coverage tell you?
Code coverage reports which parts of the code ran during tests. Line coverage may show whether lines executed; branch coverage may show whether different decision paths ran. Coverage can help identify untested areas, but it does not prove that tests checked correct behavior. A test may execute a line without asserting its result.
There is no single coverage percentage that guarantees quality for every course or project. A high score can still miss important edge cases, while a lower score may reflect code that is hard to test or outside the test’s scope. Treat coverage as a prompt for review, not as a quality grade by itself.
Mutation testing offers another signal. A tool makes small changes to code, such as changing a comparison from > to >=, and checks whether tests detect the change. If the tests still pass, they may not cover that behavior. Mutation results also require judgment: some changes may not affect observable behavior.
In academic work, grading only by coverage can reward tests that run code but do not check useful results. Strong assessment also considers whether test names and assertions express the requirement, whether boundary and failure cases make sense, and whether the tests could catch plausible mistakes.
Key takeaway: When a test fails, investigate the behavior. When coverage rises, review what the tests actually prove.
Career Pathways: Unit Testing in Software Engineering Practice
Unit testing is a practical skill across software development roles because it helps teams check changes and explain intended behavior. The details vary by language, framework, and organization. The underlying habits remain useful: choose a small scope, make expected behavior clear, and keep results repeatable.
In a course, these habits can also make assignments easier to debug. A small test can help distinguish a calculation error from a problem elsewhere in a program. In a team, readable tests can help another developer understand why a rule exists before changing the code. Tests are not a substitute for design discussion or code review, but they can support both.
Consider a student writing a function that converts a score to a letter grade. A first test might check a score in the middle of a grade band. Then the student can check the exact cutoff, one point below it, and an invalid score, using the grading rules provided by the assignment. This illustrative case shows why requirements matter: the test should reflect the stated cutoff, not an assumed one.
Before submitting code, use a short review: – Does each test name describe the behavior it checks? – Does each test assert an observable result? – Are normal, relevant boundary, and expected failure cases covered? – Does the suite run without relying on outside services or unstable state? – Do coverage results reveal areas worth reviewing? – Does the project also need integration or broader tests?
Next step: Keep tests that protect important behavior, and update them when the requirements change.
Frequently Asked Questions About Unit Testing
These short answers clarify common terms and choices. The right test scope depends on the project’s design and requirements, but the basic principles stay consistent: check behavior, keep tests repeatable, and read results with care.
What is a unit test in simple terms?
It is an automated check that a small piece of software behaves as expected for a chosen input or condition.
What is considered a unit?
A unit may be a function, method, or class. Its size depends on the language, design, and purpose of the test.
Why is unit testing important?
It can catch changes that break expected behavior and make defects easier to locate near the affected code.
What does Arrange–Act–Assert mean?
Arrange the test conditions, act by calling the unit, and assert that the result matches the expected behavior.
Do all unit tests need mocks?
No. Use mocks or stubs when they make dependencies more controlled or the test more reliable. They are not mandatory.
What is code coverage?
Coverage measures which code ran during tests. It can reveal areas to review, but it cannot show by itself that behavior was checked correctly.
Does 100% coverage prove the program is correct?
No. Code can run without its output being meaningfully checked, and tests may still miss defects.
How many unit tests should a project have?
There is no universal number. Test meaningful behaviors, including relevant boundaries and expected failures, rather than aiming for a fixed count.
What is a flaky test?
A flaky test gives inconsistent results without a relevant code change, often because it depends on timing, randomness, shared state, or unstable services.
Do unit tests replace integration tests?
No. Unit tests check small components; integration tests check how connected components work together. Most projects need tests at more than one level.
Unit testing works best as a focused way to check stated behavior, not as a promise that software has no defects. Begin with a small unit, choose cases that matter, and make every assertion explain what should happen. Use coverage and automation as supporting tools, then review the tests with the same care as the code.
(This article was written by one of our staff writers, Nathan Sterling. Visit our Meet the Team page.)
