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

Why “Simple Features” Are Often Not Simple

“It’s just a small feature” is one of the most expensive sentences in software. What looks simple on the surface often hides layers of complexity underneath.

Read more

Why Lisbon Startups Are Looking Beyond Portugal for Senior Backend Engineers

Portugal's engineering talent is genuinely strong. The senior backend engineers Lisbon startups need are increasingly hard to hire locally.

Read more

NULL in SQL Does Not Mean What You Think It Means

NULL represents the absence of a value, not zero, not an empty string, and not false — its three-valued logic and propagation rules produce query results that are consistently surprising to developers who treat it as a regular value.

Read more

How Seoul Tech Startups Are Filling Senior Backend Gaps Without Competing With the Big Players

Competing with Samsung and Kakao for backend engineers is a losing game for most startups. The ones shipping consistently have stopped playing it.

Read more