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

JPA Query Optimization — What Hibernate Generates and How to Control It

Hibernate generates SQL from your entity model and query methods. The generated SQL is often correct but rarely optimal. Understanding what gets generated — and the specific patterns that override it — determines whether JPA is a productivity tool or a performance liability.

Read more

Root Cause Analysis: Stop Fixing Symptoms and Start Fixing Problems

Most incident follow-ups fix the proximate cause — the thing that immediately broke. Root cause analysis asks what system property allowed the proximate cause to exist, and fixes that instead.

Read more

The Digital Nomad Boom Changed Lisbon's Hiring Market — and Not in Startups' Favour

Lisbon became one of the world's most desirable places to work remotely. Local startups are still figuring out what that means for their hiring.

Read more

How Projects Fail Silently Without Leadership

Sometimes a project looks fine on the surface, but small problems quietly pile up. Without strong leadership, these issues can snowball into serious failure.

Read more