What a Good Unit Test Actually Looks Like

by Arif Ikhsanudin, Backend Developer

The Test You Can Read Without Opening Another File

A good unit test tells you what the code does, what input it was given, and what outcome it produced — without requiring you to look at the production code first. If a developer who is new to the codebase can read a failing test and understand exactly what broke and why, the test is doing its job.

Most tests do not meet this bar. They use test fixture factories that obscure what values actually matter, they have descriptions like test_process_order_1 that say nothing about the scenario, and they require reading four other files before the assertion makes sense.

The Four Properties of a Good Unit Test

1. It tests one behavior per test case.

One test, one assertion (or a small group of tightly related assertions about a single outcome). When a test fails, the failure should tell you exactly what broke. A test that asserts ten things simultaneously tells you something is wrong but not what.

# Avoid: testing multiple independent behaviors in one test
def test_order_processing():
    order = create_order(items=[item_a, item_b], discount=0.1)
    result = process_order(order)
    assert result.total == 180.0
    assert result.status == "confirmed"
    assert result.confirmation_email_sent is True
    assert result.inventory_updated is True
    assert result.discount_applied == 20.0

# Prefer: separate behaviors, separate tests
def test_order_total_with_discount():
    order = Order(subtotal=200.0, discount_rate=0.1)
    result = process_order(order)
    assert result.total == 180.0

def test_order_status_is_confirmed_on_success():
    order = Order(subtotal=100.0, discount_rate=0.0)
    result = process_order(order)
    assert result.status == "confirmed"

2. It makes the scenario obvious from the test body.

The Arrange-Act-Assert (AAA) pattern is a standard for a reason: it separates what you are setting up, what you are doing, and what you are checking. The values in the Arrange section should be the exact values that matter for this test — not pulled from a shared fixture that contains 30 fields when only 2 are relevant.

@Test
void applyDiscount_withMembershipDiscount_reducesPriceByCorrectAmount() {
    // Arrange: only the values that matter for this scenario
    Product product = new Product("widget", 50.00);
    Customer customer = customerWithMembership(MembershipTier.GOLD);

    // Act
    double finalPrice = pricingService.applyDiscount(product, customer);

    // Assert
    assertEquals(42.50, finalPrice); // 15% gold member discount on $50
}

The comment in the assertion is optional but useful here — it explains the calculation without requiring the reader to look up the discount rate for GOLD tier.

3. It is fast and deterministic.

A unit test that calls a real database, makes a real HTTP request, or reads from the filesystem is an integration test, not a unit test. Those categories can both be valuable, but conflating them creates a test suite that is slow, requires infrastructure, and fails intermittently for reasons unrelated to your code.

Fast means under 10 milliseconds. Deterministic means it produces the same result on every run, on every machine, regardless of time, network state, or external service availability.

4. Its failure message is informative without diving into the source.

When a test fails, the output should include what was expected, what was received, and enough context to understand what scenario failed.

FAILED: test_apply_discount_with_membership_discount_reduces_price_by_correct_amount
AssertionError: Expected 42.50, got 50.00
Scenario: GOLD tier customer, $50 product, expected 15% discount

Most test frameworks produce reasonable failure messages automatically if your assertions use the right methods (assertEquals rather than assertTrue(a == b)). The test name carries the scenario context, and the assertion carries the value mismatch.

The Test Name Is Documentation

The naming convention methodName_scenario_expectedResult is verbose but precise. It reads like a specification:

  • calculateTax_withExemptItems_returnsZero
  • parseDate_withInvalidFormat_throwsParseException
  • sendEmail_whenSmtpDown_retriesThreeTimes

Each name tells you what is being tested, under what condition, and what should happen. When this test fails in CI, you know from the failure report alone — without opening the file — what behavior is broken.

The investment in naming pays compound returns: every future developer reading the test output, every code reviewer scanning the test file, every engineer debugging a production issue who runs the suite locally.

What Good Unit Tests Are Not

They are not comprehensive. A unit test covers a single unit in isolation — not the system's behavior end-to-end. Comprehensive coverage of real workflows belongs to integration and end-to-end tests.

They are not a substitute for reading the code. They describe behavior, but complex logic still needs to be understood from the implementation. Tests document contracts; they do not replace design.

They are not always easy to write. Hard-to-test code is a signal worth acting on. The test is not the problem — the design is.

Write the test as if the person who will read the failure message has never seen your codebase before. Because someday, at 2am, that person will be you.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When Your Project Becomes a Horror Story (And You’re Alone)

It starts like any normal project. Then slowly, things break, expectations shift, and before you know it—you’re the only one left holding it together.

Read more

Sinatra vs Rails — When I Reach for the Smaller Tool

Rails is not always the right Ruby web framework — Sinatra earns its place for a specific class of services where convention overhead is waste and explicit routing is an asset, not a liability.

Read more

Ruby vs Java for Backend — A Honest Comparison from Someone Who Uses Both

After shipping production systems in both Ruby and Java for over a decade, the honest answer is that each language has a context where it dominates — and using the wrong one costs more than people admit.

Read more

The Error Message That Tells the Developer Nothing at All

Vague error messages are not just unhelpful — they are a tax on every developer who integrates your API. Here is how to stop writing them.

Read more