Need the #1 custom application developer in Brisbane?Click here →

Testing

Unit Testing

9 min readLast reviewed: March 2026

Unit tests are the foundation of a healthy test suite. They're fast, focused, and cheap to write. A unit test exercises a single function or component in isolation, mocking everything else. When your unit tests pass, you know that individual pieces of your application work correctly.

What Is a Unit Test?

A unit test verifies the behavior of a single function or component. It assumes all dependencies (database, API, other functions) work correctly—either by mocking them or using simple test doubles. The test doesn't care about the network, the filesystem, or the database. It only cares: "Does this function produce the correct output for this input?"

A simple example: a function that calculates tax on a purchase amount. A unit test passes in a number, checks that the output is correct, and doesn't involve the shopping cart, payment system, or database. Unit tests are fast (milliseconds), deterministic (same input always produces same output), and independent (they don't depend on other tests).

Why Unit Tests Are the Foundation

Your test suite is an inverted pyramid: many unit tests at the base, fewer integration tests in the middle, few E2E tests at the top. This shape exists because:

  • Speed: Unit tests run in milliseconds. You can run thousands per second. This means fast feedback loops—you know immediately if you broke something.
  • Cost: Writing a unit test is cheap. Mocking a dependency is straightforward. You don't need to set up complex environments.
  • Determinism: Unit tests don't depend on network, timing, or external state. They pass or fail consistently.
  • Isolation: When a unit test fails, you know exactly which piece of code is wrong. No mysteries, no flakiness.
  • Design improvement: Writing testable code forces you to think about dependencies and structure. Code that's easy to unit test tends to be well-designed.

Integration and E2E tests catch different bugs, but unit tests catch the most bugs, the fastest, and the cheapest.

The Arrange-Act-Assert Pattern

The simplest way to structure a unit test is: Arrange, Act, Assert.

  • Arrange: Set up the preconditions. Create objects, initialize variables, mock dependencies.
  • Act: Call the function you're testing with your arranged inputs.
  • Assert: Check that the output is what you expected. Verify the result, or that certain methods were called.

Here's a conceptual example in pseudocode:

// Arrange
const user = { email: "test@example.com", password: "correct" }

// Act
const result = login(user.email, user.password)

// Assert
expect(result.success).toBe(true)
expect(result.token).toBeDefined()

This structure makes tests easy to read and reason about. Anyone can glance at your test and understand what it's testing.

Mocking and Stubbing

To test a function in isolation, you need to replace its dependencies with test doubles. There are a few types:

  • Mock: A fake object that tracks how it was called. You can assert that a method was called with specific arguments.
  • Stub: A fake object that returns canned responses. It doesn't track calls; it just returns what you tell it to.
  • Spy: A wrapper around the real object that tracks calls but still uses the real implementation.
  • Fake: A simplified but functional implementation (like an in-memory database for testing).

For example, if you're testing a function that sends an email, you'd mock the email service so the test doesn't actually send emails. The mock tracks that the send method was called with the right arguments.

Tip
Be conservative with mocks: Mocks are powerful but can hide real bugs. If you mock too much, your tests might pass while the real code fails. Mock external dependencies (API calls, database, email), but test real logic. If you find yourself mocking the thing you're testing, you're testing the wrong thing.

Popular Testing Frameworks

Different languages have different frameworks, but the concepts are universal:

AspectUse
LanguageJavaScript/TypeScript (primary choice for JS)
LanguageJavaScript/TypeScript (Vite-based, faster)
LanguageJavaScript/TypeScript (lightweight, flexible)
LanguagePython (simple, powerful)
LanguageRuby (expressive, BDD-style)
LanguageJava (the standard)
LanguageGo (built-in, minimal)

For React/Next.js projects, Jest and React Testing Library are the standard. Jest handles running tests and assertions; React Testing Library focuses on testing components from a user's perspective.

Testing React Components

Testing React components is unit testing for UI. You render the component, simulate user interactions, and verify it renders what you expect. React Testing Library is the recommended tool because it encourages testing behavior (what the user sees and does) rather than implementation details (component state, props structure).

A component test might render a button component, click it, and verify that a callback was called. Or render a form, fill in fields, submit, and verify the form submission handler was invoked. The key is: you're testing what the user experiences, not the internal mechanics.

What Makes a Good Unit Test

Not all unit tests are created equal. Good unit tests share these properties:

  • Fast: Runs in milliseconds. No network, no database, no file I/O.
  • Deterministic: Always passes or always fails. No flakiness from timing or randomness.
  • Independent: Doesn't depend on other tests. Tests can run in any order, in parallel, or standalone.
  • Focused: Tests one thing. Has one reason to fail. When it fails, you know what broke.
  • Clear: Easy to read and understand. Another developer can glance at it and understand what it's testing.
  • Maintainable: Doesn't test implementation details. Tests behavior. When you refactor the code, the test still passes.

A bad unit test might be slow (calls a real database), flaky (depends on timing), tightly coupled to implementation (breaks if you rename a variable), or unclear (impossible to tell what it's testing).

Testing Edge Cases and Error Paths

The happy path (when everything works) is the first test to write. But comprehensive unit tests also cover:

  • Boundary conditions: Empty lists, null values, zero, negative numbers, extremely large inputs.
  • Error cases: What happens when dependencies fail? If the database is down, does the function handle it gracefully?
  • Invalid inputs: What if the user passes the wrong type? A string instead of a number?
  • Edge cases: Off-by-one errors, special characters, unicode, very long strings.

Testing edge cases prevents bugs in production. Many bugs hide in the corners: the empty list you didn't account for, the null you didn't check for, the error you assumed couldn't happen.

Code Coverage

Code coverage measures what percentage of your code is executed by tests. It's useful but often misunderstood. High coverage (80%+) is good. But 100% coverage with poor tests is worse than 70% coverage with excellent tests. Coverage is a tool, not a goal.

Focus coverage on high-risk areas: business logic, financial calculations, authentication, anything that could break in production. Less critical code (UI styling, logging) can have lower coverage. A good rule of thumb: aim for 70-80% coverage and don't stress about the last 20%.

Note
Coverage types: Line coverage (did this line execute?), branch coverage (did both branches of an if statement execute?), and function coverage (was this function called?). Branch coverage is the most useful because it tells you if you tested both the true and false paths.

Test-Driven Development as a Design Tool

TDD (test-driven development) means writing tests before you write the code. The cycle is: write a failing test, write code to make it pass, refactor, repeat. This approach has surprising benefits beyond just preventing bugs.

When you write tests first, you think about the API before you implement it. What arguments should the function take? What should it return? This forces better design. Code written test-first tends to be more modular, with fewer dependencies, and easier to use. TDD is as much about design as about testing.

Common Unit Testing Mistakes

Avoid these pitfalls:

  • Testing implementation instead of behavior: Your test breaks when you refactor because it tests internal details. Instead, test what the function does.
  • Over-mocking: Mocking everything means your tests pass while the real code fails. Mock external dependencies, not your own code.
  • Slow tests: If a test touches the database or makes network calls, it's not a unit test. Use mocks to keep tests fast.
  • Unclear names: A test name should describe what it tests: "test_loginWithValidCredentialsReturnsToken" not "test1".
  • Testing multiple things: One test should have one reason to fail. If a test checks three different conditions, split it into three tests.
  • Brittle tests: A test that fails every time you touch the code is worse than no test. Write tests for behavior, not implementation.
  • Ignoring failures: If a test fails, fix it immediately. A failing test is a broken promise—it tells developers they can't trust the test suite.

Running Tests Locally vs in CI

Developers run tests locally during development to get fast feedback. But your full test suite should run in your CI/CD pipeline on every commit. This ensures no one accidentally breaks the build before pushing to main.

A typical workflow: developer runs tests locally (quick feedback), commits and pushes, CI runs tests again (authoritative check), and if they fail, the developer can't merge. This dual-layer approach catches accidental failures and gives confidence that main is always green.

Developer Insight
Start with unit tests: If you're new to testing, focus here. Unit tests are the easiest to learn and the most valuable. Get comfortable with mocking, assertions, and the AAA pattern. Once you're confident with units, move to integration and E2E. Building from the base of the pyramid is always the right approach.

Key Takeaways

Unit tests are fast, cheap, and the foundation of your test suite. Write many of them. Test the happy path, edge cases, and error paths. Use mocks to keep tests isolated and fast. Good unit tests are clear, focused, and deterministic. They improve design and give confidence to refactor. Test behavior, not implementation, and you'll have tests that last.