What Is Unit Testing?
Unit testing is the practice of testing the smallest testable parts of a software application — individual functions, methods, or classes — in complete isolation from the rest of the system. When you unit test a function, you call it with specific inputs and verify that it produces the expected output, with no database calls, no network requests, no file system access, and no dependency on other components.
Consider a function that calculates shipping cost:
function calculateShipping(weight, distance, isExpress):
if weight <= 0 or distance <= 0:
throw InvalidArgumentError
baseCost = weight * 0.5 + distance * 0.1
if isExpress:
return baseCost * 1.5
return baseCost
A unit test for this function would call it with known inputs and check the output:
test "standard shipping for 2kg, 100km":
result = calculateShipping(2, 100, false)
assert result == 11.0 // (2 * 0.5) + (100 * 0.1) = 11.0
test "express shipping multiplier":
result = calculateShipping(2, 100, true)
assert result == 16.5 // 11.0 * 1.5 = 16.5
test "negative weight throws error":
assertThrows InvalidArgumentError:
calculateShipping(-1, 100, false)
These tests verify the function’s behavior without starting a web server, connecting to a database, or involving any other part of the application.
The FIRST Principles
Good unit tests follow the FIRST principles, a framework that defines what separates effective unit tests from problematic ones.
Fast
Unit tests must execute in milliseconds, not seconds. A developer should be able to run the entire unit test suite after every code change without hesitation. If your suite of 500 unit tests takes 10 minutes, developers will stop running them. If it takes 3 seconds, they will run it constantly.
What slows tests down: Database connections, file I/O, network calls, sleep/wait statements, complex setup procedures.
How to keep tests fast: Use test doubles (mocks, stubs) to eliminate external dependencies. Test pure logic in isolation.
Independent
Each test must be able to run on its own, in any order, without depending on the outcome of another test. Test A should not create data that Test B relies on. Test C should not clean up state that Test D needs.
Bad pattern:
test "create user":
user = createUser("alice@test.com")
globalUser = user // saves state for next test
test "update user email":
globalUser.email = "new@test.com" // depends on previous test
update(globalUser)
Good pattern:
test "create user":
user = createUser("alice@test.com")
assert user.email == "alice@test.com"
test "update user email":
user = createUser("bob@test.com") // creates its own data
user.email = "new@test.com"
update(user)
assert user.email == "new@test.com"
Repeatable
Running the same test 100 times must produce the same result every time. No randomness, no time-dependency, no reliance on external systems.
If a test passes on Monday but fails on Tuesday because it checks today's date == "Monday", it violates repeatability. If a test sometimes fails because a network call times out, it violates repeatability.
Self-Validating
Each test must have a clear pass/fail outcome with no human interpretation required. The test asserts an expected result, and the framework reports pass or fail. A test that prints output to the console for a human to review is not self-validating.
Timely
Unit tests should be written at the right time — ideally before or alongside the code they test, not weeks or months later. In TDD (Test-Driven Development), tests are written first, then code is written to make them pass.
Writing tests late leads to untestable code — functions with too many dependencies, hidden side effects, and tight coupling.
Test Doubles: Mocks, Stubs, and Fakes
Real-world code rarely exists in isolation. A function might call a database, send an email, or query an external API. Unit tests cannot use these real dependencies (they would be slow, unreliable, and expensive), so we use test doubles — stand-in objects that replace real dependencies during testing.
Stubs
A stub provides predetermined responses to method calls. It does not verify how it was called — it just returns what you tell it to return.
// Real dependency: PaymentGateway.charge(amount) -> boolean
// Stub: always returns true (simulates successful payment)
stubPaymentGateway.charge = () => return true
test "order is confirmed when payment succeeds":
order = new Order(items, stubPaymentGateway)
order.checkout()
assert order.status == "confirmed"
Use stubs when you need to control what a dependency returns but do not care how it was called.
Mocks
A mock verifies that specific interactions occurred. It records method calls and lets you assert that the code under test called specific methods with specific arguments.
mockEmailService = new Mock(EmailService)
test "order confirmation sends email":
order = new Order(items, mockEmailService)
order.confirm()
// Verify the mock was called correctly
mockEmailService.verify("sendEmail")
.wasCalledOnce()
.withArguments("user@test.com", "Order Confirmed")
Use mocks when verifying behavior — that the code interacted with a dependency in the expected way.
Fakes
A fake is a working implementation that takes shortcuts. An in-memory database is a fake — it implements the same interface as the real database but stores data in memory instead of on disk.
// Real: PostgresUserRepository (connects to PostgreSQL)
// Fake: InMemoryUserRepository (stores in a HashMap)
fakeRepo = new InMemoryUserRepository()
test "find user by email":
fakeRepo.save(new User("alice@test.com"))
found = fakeRepo.findByEmail("alice@test.com")
assert found != null
assert found.email == "alice@test.com"
Use fakes when you need realistic behavior from a dependency but cannot use the real thing in tests.
When to Use Each
| Test Double | Use When | Verifies |
|---|---|---|
| Stub | You need to control return values | Nothing (just provides data) |
| Mock | You need to verify interactions | Method calls, arguments, call count |
| Fake | You need realistic behavior in memory | Nothing (provides real behavior) |
Code Coverage Basics
Code coverage measures how much of your source code is executed when the test suite runs. It is expressed as a percentage:
- Line coverage: What percentage of lines were executed?
- Branch coverage: What percentage of if/else branches were taken?
- Function coverage: What percentage of functions were called?
A function with 10 lines and tests that execute 8 of those lines has 80% line coverage.
The 80% trap: Many teams set a coverage target (often 80%) and treat it as a quality gate. But coverage only measures whether code was executed, not whether it was tested correctly. You can achieve 100% coverage with tests that assert nothing:
test "bad test with full coverage":
calculateShipping(2, 100, false) // runs the code, asserts nothing
This test achieves coverage but catches zero bugs.
Useful coverage guidance:
- Use coverage to find untested code, not to prove code is well-tested
- Branch coverage is more valuable than line coverage
- Focus on covering critical business logic, not utility code
- 80-90% is a reasonable target; 100% is usually not worth the effort
Who Writes Unit Tests?
Developers write unit tests. This is not a QA activity. The developer who writes the function is best positioned to write its unit tests because they understand the intended behavior, edge cases, and implementation details.
QA engineers should:
- Review unit test quality during code reviews
- Identify missing test scenarios that developers might overlook
- Advocate for adequate coverage of critical business logic
- Understand unit test reports to know where quality risk exists
Exercise: Write Unit Test Scenarios for a Calculator
Consider this calculator module with four functions:
function add(a, b):
return a + b
function divide(a, b):
if b == 0:
throw DivisionByZeroError
return a / b
function percentage(value, percent):
if percent < 0 or percent > 100:
throw InvalidPercentageError
return value * (percent / 100)
function compound(principal, rate, years):
if principal < 0 or rate < 0 or years < 0:
throw InvalidArgumentError
return principal * (1 + rate) ^ years
Write test scenarios (name, input, expected output) for each function. Cover:
- Happy path (normal operation)
- Edge cases (zero, boundary values)
- Error cases (invalid input)
- Special values (very large numbers, decimals)
Hint
For each function, think about: What is the simplest valid input? What happens at boundaries (0, negative numbers, very large numbers)? What inputs should cause errors? Are there precision issues with decimal arithmetic?Solution
add(a, b) tests:
add(2, 3)→5— basic positive numbersadd(0, 0)→0— zero valuesadd(-3, 5)→2— negative and positiveadd(-3, -7)→-10— both negativeadd(0.1, 0.2)→0.3— decimal precision (watch for floating-point issues!)add(999999999, 1)→1000000000— large numbersadd(MAX_INT, 1)→ check for overflow behavior
divide(a, b) tests:
divide(10, 2)→5— basic divisiondivide(10, 3)→3.333...— non-integer resultdivide(0, 5)→0— zero numeratordivide(10, 0)→ throwsDivisionByZeroError— division by zerodivide(-10, 2)→-5— negative numeratordivide(10, -2)→-5— negative denominatordivide(-10, -2)→5— both negativedivide(1, 3)→0.333...— verify decimal precision
percentage(value, percent) tests:
percentage(200, 50)→100— basic percentagepercentage(100, 0)→0— zero percent (boundary)percentage(100, 100)→100— 100 percent (boundary)percentage(100, -1)→ throwsInvalidPercentageError— below rangepercentage(100, 101)→ throwsInvalidPercentageError— above rangepercentage(0, 50)→0— zero valuepercentage(99.99, 33.33)→33.326667— decimal precision
compound(principal, rate, years) tests:
compound(1000, 0.05, 10)→1628.89— basic compound interestcompound(1000, 0, 10)→1000— zero ratecompound(1000, 0.05, 0)→1000— zero yearscompound(0, 0.05, 10)→0— zero principalcompound(-1, 0.05, 10)→ throwsInvalidArgumentError— negative principalcompound(1000, -0.01, 10)→ throwsInvalidArgumentError— negative ratecompound(1000, 0.05, -1)→ throwsInvalidArgumentError— negative yearscompound(1000, 1.0, 30)→ very large number — verify no overflow
Advanced Unit Testing Patterns
Arrange-Act-Assert (AAA)
Structure every unit test in three clear phases:
test "expired coupon is rejected":
// Arrange — set up test data and dependencies
coupon = new Coupon("SAVE10", expiryDate: "2024-01-01")
validator = new CouponValidator(clock: fixedClock("2024-06-15"))
// Act — execute the behavior being tested
result = validator.validate(coupon)
// Assert — verify the outcome
assert result.isValid == false
assert result.reason == "Coupon has expired"
This pattern makes tests readable and maintainable. Anyone can look at a test and immediately understand what is being set up, what action is taken, and what outcome is expected.
Parameterized Tests
When multiple test cases share the same logic but differ only in input/output, use parameterized tests to avoid duplication:
@parameterized([
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
test "add returns sum of two numbers"(a, b, expected):
assert add(a, b) == expected
One test definition, multiple data sets. Much cleaner than writing four separate test functions.
Test Naming Conventions
Good test names describe the scenario and expected outcome:
test_divide_by_zero_throws_error— clear and specifictest_expired_coupon_returns_invalid— describes behaviortest_new_user_gets_welcome_email— reads like a requirement
Bad test names say nothing useful:
test1— meaninglesstestDivide— what about divide?testIt— test what?
Pro Tips
Tip 1: One assertion per test (usually). A test that asserts five different things is really five tests crammed into one. When it fails, you do not know which aspect broke. Exceptions exist for closely related assertions (e.g., checking both properties of a returned object).
Tip 2: Do not test implementation details. If you refactor a function without changing its behavior, zero tests should break. If your tests break because you renamed an internal variable, they are testing the wrong thing.
Tip 3: Use test doubles sparingly. Every mock is a lie about the real system. Too many mocks and your tests verify your mocking configuration, not your code. If a function is hard to test without many mocks, the function might need refactoring.
Key Takeaways
- Unit tests verify individual functions in complete isolation
- The FIRST principles (Fast, Independent, Repeatable, Self-validating, Timely) define quality
- Test doubles (stubs, mocks, fakes) replace real dependencies in unit tests
- Code coverage measures execution, not correctness — use it to find gaps, not prove quality
- Developers write unit tests; QA reviews and identifies missing scenarios
- The AAA pattern (Arrange-Act-Assert) keeps tests clean and readable