8 Signs of Poorly Written Unit Tests

Spot eight common unit testing mistakes, including weak assertions, hidden exceptions and shared state. See focused Java examples and practical fixes.

best practices

A passing test is useful when it checks a behavior that matters. A test can also pass because it never checks the result, uses the wrong input or replaces the very behavior it claims to test. We need to read the test’s setup and assertions before deciding what its green result means.

The eight signs below help us review Java unit tests. Each one includes a way to improve the test without turning a useful guideline into an absolute rule. The snippets use JUnit Jupiter and show test methods rather than complete classes. Their containing classes supply the application objects, setup and assertion imports.

Eight signs of weak tests including missing assertions, unrelated checks, mixed behaviors, private details, swallowed exceptions, unclear setup, external state and manual result checking

1. The Test Passes Without Checking the Feature

Our illustrative calculator’s total(unitPrice, quantity) method multiplies the unit price by the quantity. Consider a test that calls it and prints the result.

@Test
void calculatesTotal() {
    int total = calculator.total(200, 3);
    System.out.println(total);
}

This test fails if the call throws an unexpected exception, but it does not fail when the calculator returns 500 instead of 600. We need an assertion for the required value.

@Test
void multipliesUnitPriceByQuantity() {
    int total = calculator.total(200, 3);

    assertEquals(600, total);
}

The test now checks the arithmetic result. In this example, amounts are integer minor units, such as cents. If we temporarily change the calculation to addition, this test should fail. This is a useful way to check whether an assertion can detect the mistake it is meant to catch.

2. The Assertions Check Something Unrelated

A test may execute a method but assert only that its input object still exists. That observation usually says nothing about the operation’s result.

We first describe the promised behavior. If the method calculates a delivery fee, we assert that fee. If it registers a user, we check the relevant registration result or the important interaction with storage.

Stubbing means configuring a test double, a replacement dependency used in a test, to return a prepared result. We avoid stubbing the method under test. If we tell a mocked calculator to return 600 and then assert that it returns 600, we are checking the mock setup. The real calculation never runs. The DAO testing guide explains the same limitation for mocked database queries.

3. One Test Checks Several Unrelated Behaviors

A test that creates a user, changes a password, updates an address and deletes the account has several reasons to fail. Its name is unlikely to explain all of them, and an early failure may prevent later checks from running.

We give separate behaviors their own tests and arrange the required starting state in each one. The password test does not need to rely on the create-user test having run first.

One behavior does not mean one assertion. An address conversion test may need to check the street, city and postal code. Those assertions describe one result and can stay together. JUnit’s assertAll() can report failures from several related assertions in the same run.

4. The Test Depends on Private Implementation Details

A test that uses reflection to call a private helper may fail when we rename or reorganize that helper, even if the class still behaves correctly. Reflection lets code inspect or access members indirectly, which can bypass the public API that callers actually use.

We usually test the behavior through the public operation. If a private helper contains substantial independent logic, extracting that logic into a focused component may give us a useful boundary to test directly.

Reflection is not always wrong. Frameworks and reflection-based libraries may need tests of reflective behavior itself. The warning sign is unnecessary dependence on internal details in an ordinary application behavior test.

5. The Test Catches an Exception and Still Passes

For this calculator, the business rule requires a positive quantity. The following pattern hides a failure because the test completes normally after the catch block.

@Test
void rejectsZeroQuantity() {
    try {
        calculator.total(200, 0);
    } catch (IllegalArgumentException exception) {
        System.out.println(exception.getMessage());
    }
}

If zero quantity must be rejected, we use an expected-exception assertion.

@Test
void rejectsZeroQuantity() {
    assertThrows(IllegalArgumentException.class,
            () -> calculator.total(200, 0));
}

This fails if the method returns normally. It also fails if an unrelated exception type is thrown. We check the message or an error code only when that detail is part of the contract we need to protect.

For an ordinary success test, we usually let unexpected exceptions reach the test runner. We do not need a catch block that only calls fail().

6. The Setup Hides the Relevant Inputs

A test may create dozens of objects before reaching one assertion. A reader then has to work out which values matter and which were copied from another test.

We keep the important values visible. In this separate delivery example, an order qualifies for free delivery at 5,000 cents. The order amount belongs in the test, even if a helper fills in an unimportant customer address.

@Test
void givesFreeDeliveryAtTheThreshold() {
    Order order = orderWithAmount(5000);

    int fee = deliveryFees.forOrder(order);

    assertEquals(0, fee);
}

The helper orderWithAmount() prepares an order with the requested amount and valid default details. It should not silently select the amount being tested. This keeps the business condition readable.

A large setup is not automatically wasteful in an integration test. We review it against the test’s purpose. If a small calculation test needs an entire application to start, however, we can usually simplify its dependencies.

7. The Test Works Only on One Machine or in One Order

Hard-coded file paths, shared database records, the current date and environment-specific settings can make tests fail outside a developer’s machine. Ordered tests can hide the same issue by relying on data created earlier.

JUnit Jupiter’s \@TempDir provides a temporary directory for a file test. A fixed Clock controls time-dependent code. Each database test needs known data and an isolation strategy.

We run a suspicious test alone as well as with the suite. A difference between those runs often points to shared state. The FIRST principles explain independence and repeatability in more detail.

8. Console Output Is the Only Way to Understand the Result

Logs can help diagnose failures, but a test must not depend on a person reading them to decide whether it passed. Repeated full object dumps can also hide the useful failure message in a long build log.

We replace manual result checking with clear assertions. We retain targeted diagnostic output where it helps, especially for an intermittent integration failure. Testing a logging requirement may also legitimately involve captured log events.

The distinction is between checking behavior and explaining a failure. Assertions do the first job. Carefully chosen diagnostics can help with the second. The JUnit assertion documentation provides the available assertion methods and their failure behavior.

9. Conclusion

When reviewing a unit test, we ask what real behavior it executes, which mistake would make it fail and whether it controls its starting state. Clear answers to those questions matter more than the number of assertions, the amount of mocking or a green test count.

Happy Learning!

Leave a Comment

  1. 8) It is not a real problem if your unit tests produce logs. Those are presumably test logs. It is a real problem if the log files produced by unit tests somehow mix with production log files.

  2. Thanks! New to programming and can tell my tests aren’t written well, nice to have a list of things to keep in mind.

  3. Loved this post for writing your expression. It is not just a collection of technical words.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.