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_returnsZeroparseDate_withInvalidFormat_throwsParseExceptionsendEmail_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.