Skip to content
Go back

A Practical View of Unit Testing, TDD, and Specification

Functional testing is commonly divided into three main levels: Unit Testing, Integration Testing, and End-to-End Testing. Unit tests focus on individual units of code, integration tests verify how multiple components work together—such as databases, APIs, or external services—and E2E tests validate a complete workflow from beginning to end. This is also the idea behind the Testing Pyramid: most tests should be unit tests, followed by a smaller number of integration tests, with only a small portion of end-to-end tests. The goal is to keep most tests fast, focused, and easy to maintain while using integration and E2E tests where they provide additional value.

Unit Testing

A unit test should focus on the behavior of a unit rather than its internal implementation details. One important principle is “test behavior, not implementation.” This means testing the public function or interface of a component instead of writing tests for every private function inside it.

For example, rather than writing separate tests for validateEmail(), normalizeEmail() and saveUser(), we can focus on the behavior of createUser(). We can verify that a valid email creates a user successfully, an invalid email returns a validation error, and an existing email returns the appropriate error. The private functions are tested indirectly through the public function.

This approach makes tests less dependent on implementation details. If we later change how email normalization works or how the user is stored, the tests for createUser() should not need to change as long as the externally visible behavior remains the same. On the other hand, if a small implementation change causes many unit tests to break, that may be a sign that the tests are too tightly coupled to the implementation.

A unit test should also be isolated. If the function under test depends on a database, external API, or another service, we can use test doubles such as mocks, stubs, or fakes to replace those dependencies. The goal is to test the behavior of the current unit without accidentally turning the unit test into an integration test.

However, mocks should not be used mechanically. If a test requires a large number of mocks and expectations, the problem may not be the test itself — the source code might have too many dependencies, or a function might be taking on too many responsibilities. In that situation, adding more mocks may only hide the underlying design problem, and refactoring the production code may be a better solution.

Test Behavior, Not Just the Happy Path

A common mistake when writing tests is to focus only on the case where everything works as expected. Consider a function such as createOrder(). The happy path might be simple: a valid user, a valid product, a successful payment, and the order is created.

But real systems have many other possibilities. The product might not exist. The quantity might be zero or negative. The user might not have permission. The database might fail. The payment service might time out. The input might have an invalid format. These are often the situations where bugs appear.

When writing tests, it is therefore useful to think about several categories of scenarios: happy paths, failure scenarios, security cases, and edge or corner cases. Instead of asking only “Does this function work?”, we should also ask “What happens when things don’t go as expected?” That question often leads to much more valuable tests.

Edge cases are especially easy to overlook. Empty strings, empty arrays, zero, negative numbers, minimum and maximum allowed values, extremely long inputs, and values around a boundary can all expose bugs that a normal test case will never find.

Code Coverage Is Not the Final Goal

Code coverage is a useful metric, but it should not be treated as proof that the system has been fully tested. It is possible to achieve 100% code coverage and still have bugs.

For example, a test suite may execute every line of a function but never test an input outside the expected range. The code has been executed, so coverage is high, but an important behavior has never actually been verified.

In other words, code coverage tells us that code was executed, not that all important behavior was tested. Coverage is still useful as a signal. However, instead of asking only whether we reached a certain coverage percentage, we should ask “What scenarios does this function have, and have the important ones been tested?” That is a much more meaningful question.

Test-Driven Development

Test-Driven Development, or TDD, is an approach where tests are used to drive the implementation. A typical TDD cycle looks like this:

Write Test
    ↓
Run Test → Fail
    ↓
Write the simplest implementation
    ↓
Run Test → Pass
    ↓
Refactor
    ↓
Repeat

Suppose we need to implement calculateShippingFee(). Instead of writing the entire implementation first, we can start with a simple scenario: for an order above the free-shipping threshold, the fee should be 0. The test initially fails because the implementation does not exist yet or does not satisfy the expected behavior. We then write the simplest implementation that makes the test pass.

As more requirements arrive, we add more scenarios. An order below the threshold pays a shipping fee, an order sitting exactly at the threshold still ships free, and an invalid order returns an error. The implementation gradually evolves as more behavior is specified.

One useful aspect of TDD is that we do not have to design the entire implementation upfront. The design can evolve through the cycle of writing tests, implementing the behavior, and refactoring.

The tests also provide a safety net. Once the behavior is covered by tests, we can change the internal implementation with more confidence that regressions will be detected.

Behavior-Driven Development

Behavior-Driven Development, or BDD, focuses more explicitly on describing system behavior. A scenario is often expressed using the Given – When – Then structure.

For example:

Given an empty shopping cart
When a product is added
Then the cart should contain one product

Given describes the initial state, When describes the action being performed, and Then describes the expected behavior.

This makes a test look more like a small specification for the system. When reading the test, developers can understand what the component is expected to do without necessarily having to read the entire implementation. BDD is therefore not just about changing the syntax of a test — the more important idea is to make behavior and expectations explicit.

Test Naming

Test names also matter because they help communicate the scenario being tested. A good test name should make it reasonably clear what function is being tested, what scenario is being tested, and what behavior is expected.

For example:

test_pop__when_stack_is_empty()

or:

it_should_raise_error_when_popping_item_from_empty_stack()

There is no single naming convention that works for every project. What matters most is consistency and readability. As a test suite grows, good names can become a form of documentation: by looking at the test names alone, we can often understand a significant part of a component’s expected behavior.

Specification-Driven Development

TDD is not the only way to start from behavior. Another approach is Unit Specification-Driven Development (USDD). Instead of constantly switching between tests and implementation, we can first define the specification and scenarios, then turn them into tests and implementation.

For example, for a ConfigParser, we might define the expected behavior before writing the code:

Valid JSON
→ return Config

Invalid JSON
→ return error

Missing required property
→ return error

Missing optional property
→ use default value

Invalid property type
→ return error

Value out of range
→ return error

Once these scenarios are clear, we can create the corresponding tests and start implementing the function. This approach can be particularly useful when a function contains many rules or failure scenarios, because instead of getting pulled immediately into implementation details, we first ask what the function is actually supposed to do.

The initial specification does not have to be perfect either. During implementation, we may discover a scenario that was not considered initially. When that happens, the new scenario should become part of the specification and test suite rather than simply being handled inside the implementation. Over time, the test suite becomes a kind of executable specification for the system.

A Simple Way to Write Better Tests

We do not necessarily need a complicated testing methodology to improve our tests. Before implementing a function, we can start with a few simple questions:

What should this function do?

What is the happy path?

Where can it fail?

Are there invalid or security-related inputs?

What edge cases are easy to miss?

The answers can then become test scenarios.

For example:

createUser()

valid user
→ created

existing email
→ error

invalid email
→ error

missing required field
→ error

empty input
→ error

extremely long input
→ error

These scenarios can then be turned into unit tests.

The most important part is not necessarily choosing between TDD, BDD, or USDD. They are different workflows, but they share a common idea: define the expected behavior and scenarios first, then use tests to protect that behavior.

Instead of starting with “How should I write a test for this code?”, we can start with something simpler:

“What should this code do, and what scenarios can happen?”

Once the specification and scenarios are clear, writing the tests—and eventually the implementation—becomes much easier.

And when a bug is discovered, we can ask one more question: what scenario did we miss? If that scenario becomes a test, every bug we discover can make not only the code better, but also the test suite stronger.

Previous Post
Next Post