Unit Testing Fundamentals

Unit tests are the foundation of any testing strategy. They are small, fast, focused tests that verify individual units of code — typically a single function, method, or class — in isolation from the rest of the system. A well-written unit test suite gives you immediate feedback when something breaks, serves as living documentation of how your code is expected to behave, and provides the confidence to refactor without fear.

Unit tests sit at the base of the testing pyramid. They are the cheapest to write, the fastest to run, and the easiest to maintain. A healthy codebase typically has hundreds or thousands of unit tests that execute in seconds, providing a rapid feedback loop during development.

What Unit Tests Are and Why They Matter

A unit test exercises a single "unit" of work. What constitutes a unit depends on context: in a functional codebase, it might be a single pure function; in an object-oriented codebase, it might be a method on a class. The key characteristic is that the test is isolated — it does not depend on databases, network calls, file systems, or other external systems.

Unit tests matter for several reasons:

  • Fast feedback: A failing unit test tells you within seconds that something is wrong. This is orders of magnitude faster than discovering bugs through manual testing or, worse, in production.
  • Regression prevention: Once you write a test for a bug fix, that bug can never silently return. The test acts as a permanent guard against regression.
  • Design pressure: Code that is hard to unit test is usually poorly designed. Writing tests pushes you toward better architecture: smaller functions, clearer interfaces, and fewer hidden dependencies.
  • Documentation: Well-named tests describe what your code does in concrete terms. Reading the test suite tells a new developer what behaviors the system supports and what edge cases have been considered.
  • Refactoring confidence: When you have comprehensive unit tests, you can restructure and optimize code knowing that the tests will catch any behavioral changes you did not intend.

The AAA Pattern: Arrange, Act, Assert

The AAA pattern is the most widely used structure for writing unit tests. Every test follows three distinct phases:

  • Arrange: Set up the test conditions. Create objects, initialize data, configure mocks, and prepare everything the test needs. This phase answers the question: "Given this setup..."
  • Act: Execute the code under test. Call the function or method you are testing. This is usually a single line of code. This phase answers: "When I do this..."
  • Assert: Verify the result. Check that the output matches expectations, that the right side effects occurred, or that the right exceptions were thrown. This phase answers: "Then I expect this."

Here is a concrete example in JavaScript using Jest:

// Arrange
const cart = new ShoppingCart();
cart.addItem({ name: 'Widget', price: 9.99, quantity: 2 });
cart.addItem({ name: 'Gadget', price: 24.99, quantity: 1 });

// Act
const total = cart.calculateTotal();

// Assert
expect(total).toBe(44.97);

The AAA pattern makes tests readable and consistent. Anyone looking at the test can immediately understand what scenario is being tested, what action triggers the behavior, and what the expected outcome is.

Test Isolation and Mocking

Unit tests must be isolated. If your function calls a database, sends an email, or makes an HTTP request, you do not want your unit test to actually perform those operations. External dependencies make tests slow, flaky, and hard to set up. Instead, you replace real dependencies with test doubles.

There are several types of test doubles:

  • Stubs return predetermined data. If your function calls a weather API, a stub might always return "72 degrees, sunny." Stubs answer the question: "What should this dependency return for this test scenario?"
  • Mocks verify that certain interactions occurred. A mock email service does not send real emails but records that send() was called with the right arguments. Mocks answer: "Did my code interact with this dependency correctly?"
  • Spies wrap real implementations and record calls. They let the real code run while also tracking how the dependency was used. Spies answer: "What happened when I called this real dependency?"
  • Fakes are lightweight implementations of real dependencies. An in-memory database is a fake — it behaves like a real database but runs entirely in memory without any setup.

Modern testing frameworks make mocking straightforward. In Jest, you can mock an entire module with jest.mock(). In Python's pytest, you use unittest.mock.patch(). In Dart's flutter_test, you use packages like mockito to generate mock classes.

Key principle: Mock what you do not own. If your code depends on a third-party API, database driver, or framework component, mock it. If it depends on your own utility functions, consider whether mocking them actually helps or just couples your test to implementation details.

What to Test

Not all code needs the same level of unit test coverage. Focus your testing effort where it provides the most value:

  • Business logic: This is where unit tests shine. Calculations, transformations, validation rules, and decision logic should be thoroughly tested. If a function determines pricing tiers, applies discount rules, or validates user input, it needs comprehensive unit tests.
  • Edge cases: What happens when the input is null, empty, negative, or extremely large? What about boundary conditions — the first item, the last item, exactly at the limit? Edge cases are where bugs hide, and unit tests are the most efficient way to cover them.
  • Error handling: Test that your code fails gracefully. Does it throw the right exception when given invalid input? Does it return a sensible default when a dependency is unavailable? Does it log the right error message?
  • State transitions: If your code manages state (a finite state machine, a workflow engine, a game), test each valid transition and verify that invalid transitions are rejected.
  • Parsing and serialization: Code that transforms data from one format to another (JSON parsing, CSV generation, date formatting) is an excellent candidate for unit testing because the inputs and outputs are well-defined.

What NOT to Test

Knowing what not to test is just as important as knowing what to test. Testing the wrong things leads to brittle tests that break with every refactor and provide little value:

  • Implementation details: Do not test how your code does something; test what it does. If you test that a function calls a private helper method in a specific order, your test will break when you refactor the internals even though the behavior is unchanged.
  • Trivial getters and setters: A getter that simply returns a field value does not need a test. There is no logic to verify. Your testing effort is better spent elsewhere.
  • Framework and library code: Do not test that React renders a component or that Express routes a request. Those frameworks have their own test suites. Test your code that uses the framework, not the framework itself.
  • Configuration: Testing that a constant equals a specific value adds no safety. Configuration is verified through integration or end-to-end tests that use the configuration in context.

Testing Frameworks by Language

Every major programming language has mature testing frameworks. Here are the most popular options:

  • JavaScript/TypeScript: Jest is the dominant choice. It includes a test runner, assertion library, and mocking framework in one package. Vitest is a newer alternative optimized for Vite-based projects.
  • Python: pytest is the de facto standard. It uses simple assert statements, supports fixtures for test setup, and has a rich plugin ecosystem. The built-in unittest module is also available but more verbose.
  • Java: JUnit 5 is the standard, paired with Mockito for mocking. AssertJ provides fluent assertions that are more readable than JUnit's built-in assertions.
  • Dart/Flutter: flutter_test is built into the Flutter SDK. It provides widget testing, golden image testing, and integration with the mockito package for mocking.
  • Go: The built-in testing package is sufficient for most use cases. The testify library adds assertion helpers and mocking support.
  • Rust: Rust has built-in testing support with #[test] attributes. The cargo test command discovers and runs tests automatically.

Writing Testable Code

The single biggest factor in whether your code is easy to test is its design. Two principles make code inherently testable:

Dependency Injection: Instead of creating dependencies inside your functions, accept them as parameters. A function that takes a UserRepository as a parameter is easy to test — you can pass in a mock. A function that creates its own database connection internally is nearly impossible to unit test without hacks.

// Hard to test: creates its own dependency
function getUser(id) {
  const db = new Database('production-connection-string');
  return db.query('SELECT * FROM users WHERE id = ?', [id]);
}

// Easy to test: accepts dependency as parameter
function getUser(id, db) {
  return db.query('SELECT * FROM users WHERE id = ?', [id]);
}

Pure Functions: A pure function always returns the same output for the same input and has no side effects. Pure functions are the easiest code to test because you only need to verify inputs and outputs — there is no hidden state to worry about.

// Pure function: easy to test
function calculateDiscount(price, discountPercent) {
  return price * (1 - discountPercent / 100);
}

// Impure function: harder to test (depends on current date)
function isOnSale(product) {
  const now = new Date();
  return now >= product.saleStart && now <= product.saleEnd;
}

To make the impure function testable, inject the current date as a parameter or use a clock abstraction that can be controlled in tests.

Test-Driven Development (TDD)

Test-Driven Development is a practice where you write the test before you write the implementation. The cycle is often called Red-Green-Refactor:

  1. Red: Write a test for the behavior you want. Run it and watch it fail. This confirms the test is actually testing something and that the behavior does not already exist.
  2. Green: Write the minimum code necessary to make the test pass. Do not optimize or polish — just make it work.
  3. Refactor: Clean up the code while the tests are green. Improve naming, extract helper functions, remove duplication. The tests ensure you do not break anything.

TDD is not universally practiced, but it has clear benefits. It forces you to think about the desired behavior before writing code. It guarantees that every piece of business logic has a corresponding test. And the resulting code tends to be more modular and testable because it was designed from the test's perspective.

Practical tip: If you are new to TDD, start by applying it to bug fixes. When a bug is reported, write a test that reproduces the bug first (Red), fix the bug (Green), then clean up (Refactor). This is an excellent way to build TDD habits without the pressure of designing new features test-first.

Common Unit Testing Mistakes

Even experienced developers make these mistakes. Being aware of them helps you write better tests from the start:

  • Testing too much in one test: Each test should verify one behavior. If a test has five assertions checking different things, split it into five tests. When a multi-assertion test fails, you cannot immediately tell which behavior broke.
  • Tests that depend on each other: Tests must be independent. They should be able to run in any order and still pass. If Test B depends on state created by Test A, a change in Test A silently breaks Test B.
  • Over-mocking: If you mock everything, your tests verify that your mocks work, not that your code works. Mock the boundary (external services, databases, APIs) and let your own code run for real whenever practical.
  • Fragile assertions: Asserting on an entire JSON blob when you only care about one field makes tests break for irrelevant reasons. Assert on the specific values you care about.
  • Ignoring test maintenance: Tests are code. They need the same care as production code: clear naming, no duplication, proper abstraction. A messy test suite becomes a burden that teams abandon rather than maintain.

Resources