Unit Testing Best Practices: A Complete Guide for Better Code Quality

Introduction

Summary

Unit testing is a great way to validate that a small unit of code meets its design and behaves as expected. A unit of code can be a class, a method, a static function, or a portion of these.

To write reliable unit tests, programmers need to understand the characteristics of a good unit test and follow proven best practices. This tutorial walks through the most important practices you should follow when writing unit tests.

The examples here use Java and JUnit, but these principles work with pretty much any programming language and testing framework out there.

Characteristics of a good unit test

Before we jump into specific practices, let’s establish what makes a unit test actually good. Getting these characteristics right will improve not just your tests, but your entire codebase. Here are the key ones you need to know.

Automated

Running unit tests and checking their results should happen automatically. Modern IDEs like IntelliJ IDEA and Eclipse can run your tests and show you the results without any manual work.

Fast

A good unit test runs fast – we’re talking milliseconds here.

Isolated

Unit tests need to run in complete isolation. Your test shouldn’t depend on external resources or other tests. External resources include things like databases, file systems, REST APIs, SOAP web services, and similar dependencies.

Self-checking

This reinforces the isolation idea. Your test should automatically know if it passed or failed – no manual checking required.

Descriptive

Unit tests are code too. Anyone reading your test should immediately understand what it does and what result it expects.

Repeatable

Run the same test under the same conditions, and you should get the same result every single time. If a test randomly fails or passes, that’s a red flag for poor quality.

List of the unit testing best practices

Write trustworthy unit tests

A trustworthy test does what it says on the name. It fails when code breaks, and passes only when code works correctly. Miss either of these and your test isn’t worth trusting.

Bad example

This test isn’t trustworthy. The name says we’re checking if balance updates after a deposit, but look at that assertion – it’s way too weak. Even if deposit() does absolutely nothing, this test still passes. Not good.

@Test
void deposit_Should_Update_Balance_when_Non_Null_Amount_Provided(){
    // arrange
    double AMOUNT = 100.00;
    BankAccount bankAccount = new BankAccount();
    
    // act
    bankAccount.deposit(AMOUNT);
    
    // assert
    assertTrue( bankAccount.getBalance() > 0 );
}

Better approach

Here’s the fix – define what you actually expect and check for it precisely:

@Test
void deposit_Should_Update_Balance_when_Non_Null_Amount_Provided(){
    // arrange
    double AMOUNT = 100.00;
    double EXPECTED_BALANCE = 100.00;
    BankAccount bankAccount = new BankAccount();
    
    // act
    bankAccount.deposit(AMOUNT);
    
    // assert
    assertEquals(EXPECTED_BALANCE, bankAccount.getBalance());
}

Quick note: Using primitive double for money is a bad idea in Java. In real code, stick with BigDecimal for monetary values.

Write descriptive unit tests

Your test should be easy to understand. When someone reads it, they shouldn’t have to dig around to figure out what it does, what it expects, or when it applies.

Test naming matters a lot here. Pick a naming convention and stick with it. Here are some popular ones:

  • UnitUnderTest_StateUnderTest_ExpectedBehavior – Clean and readable. Just remember to rename tests if your method names change. You can swap the order of StateUnderTest and ExpectedBehavior if you prefer.
  • Should_ExpectedBehavior_When_StateUnderTest – Works well but gets repetitive with “Should” everywhere.
  • When_StateUnderTest_Expect_ExpectedBehavior – Notice this skips the unit name. That’s fine since you’re testing behavior anyway.

Of course, the coding standards you follow in production code apply also in unit tests code.

Bad example

Let’s say you have a calculateTotal() method that adds up product prices:

@Test
void test1(){
    // Seriously the worst name possible
}

@Test
void testCalculateTotal(){
    // Too vague - doesn't explain the expected behavior or conditions
}

Better approach

@Test
void calculateTotal_Should_Return_Zero_When_No_Products(){
}

@Test
void Should_ThrowException_When_Max_Capacity_Reached(){
}

Pro tip: JUnit has a @DisplayName annotation if you want even more descriptive test names.

Arrange Act Assert

Structure matters. The Arrange-Act-Assert pattern gives your tests a clear three-part structure:

1- Arrange

Set up the objects and variables your test needs. This works alongside your general setup (like @Before or @BeforeEach methods in JUnit Jupiter).

2- Act

Call the behavior you’re testing. Usually just a method call.

3- Assert

Check if you got what you expected. This determines pass or fail. Multiple assertions are fine as long as they’re all testing the same behavior.

Note: The AAA comments are optional. Use them if they help, skip them if they don’t – just keep the three sections clearly separated.

Avoid multiple acts

When using AAA, stick to one act per test. Need to test different parameters? Use parameterized tests instead.

JUnit supports parameterized tests, and they’re great for keeping your focus on one case at a time. When something fails, you know exactly which input caused the problem.

Bad example

Here’s a NumbersUtility.isOdd() method that checks if a number is odd:

@Test
void isOdd_ShouldReturnTrueForOddNumbers() {
    assertTrue(NumbersUtility.isOdd(1));
    assertTrue(NumbersUtility.isOdd(3));
    assertTrue(NumbersUtility.isOdd(5));
    assertTrue(NumbersUtility.isOdd(-3));
    assertTrue(NumbersUtility.isOdd(Integer.MAX_VALUE));
}

Better approach

Parameterized tests handle this beautifully. When it fails, JUnit tells you exactly which parameter broke:

@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE})
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(NumbersUtility.isOdd(number));
}

Test one thing at a time

Unit testing is about checking individual pieces of your software. When you start testing multiple components together, that’s integration testing – different purpose entirely.

Both types are valuable, but they serve different goals. In a unit test, focus on one thing. When it fails, you want to know immediately what broke without having to investigate.

Bad example

This SortUtils test checks three sorting methods at once. If it fails, which one broke?

@Test
void testSortUtils(){
    final int[] NUMBERS_ARRAY = {10, 1, 5, -5, 0};
    final int[] SORTED_ARRAY = {-5, 0, 1, 5, 10};
    
    int[] quickSortResult = SortUtils.quicksort(NUMBERS_ARRAY);
    int[] bubbleSortResult = SortUtils.bubbleSort(NUMBERS_ARRAY);
    int[] insertionSortResult = SortUtils.insertionSort(NUMBERS_ARRAY);
    
    assertArrayEquals(SORTED_ARRAY, quickSortResult);
    assertArrayEquals(SORTED_ARRAY, bubbleSortResult);
    assertArrayEquals(SORTED_ARRAY, insertionSortResult);
}

Better approach

Split it up. Each test focuses on one method. Sure, these are minimal examples – in real life you’d test more scenarios:

final static int[] NUMBERS_ARRAY = {10, 1, 5, -5, 0};
final static int[] SORTED_ARRAY = {-5, 0, 1, 5, 10};

@Test
void quickSort_Should_Return_Sorted_Array(){
    int[] quickSortResult = SortUtils.quicksort(NUMBERS_ARRAY);
    assertArrayEquals(SORTED_ARRAY, quickSortResult);
}

@Test
void bubbleSort_Should_Return_Sorted_Array(){
    int[] bubbleSortResult = SortUtils.bubbleSort(NUMBERS_ARRAY);
    assertArrayEquals(SORTED_ARRAY, bubbleSortResult);
}

@Test
void insertionSort_Should_Return_Sorted_Array(){
    int[] insertionSortResult = SortUtils.insertionSort(NUMBERS_ARRAY);
    assertArrayEquals(SORTED_ARRAY, insertionSortResult);
}

Write isolated unit tests

Tests must run in complete isolation. Don’t let them depend on external resources or other tests. Databases, file systems, web services – these all count as external dependencies.

When your code has hard dependencies, create fake versions (mocks) and control their behavior for each test. This is called mocking. Libraries like Mockito, PowerMock, and EasyMock make this easy in Java.

Bad example

Check out this simplified BankAccount class:

public class BankAccount {
    private AccountService accountService;
    
    public BankAccount(AccountService accountService){
        this.accountService = accountService;
    }
    
    public BigDecimal getBalance(Integer accountNumber) {
        return accountService.getBalance(accountNumber);
    }
}

Now look at this test – it’s not isolated at all. We want to test BankAccount.getBalance(), not AccountService (remember: one thing at a time). But this test creates a real AccountService instance. That’s asking for trouble – you’re hitting a real service, which could mess up data or cause unexpected side effects:

class BankAccountTest {
    private AccountService accountService;
    private BankAccount bankAccountInstance;
    private final Integer ACCOUNT_NUMBER = 123456789;
    
    @BeforeEach
    void init(){
        accountService = AccountService.getInstance(); // Real instance - bad!
        bankAccountInstance = new BankAccount(accountService);
    }
    
    @Test
    void getBalance_Should_Return_Correct_Balance(){
        final BigDecimal EXPECTED_AMT = new BigDecimal("100.00");
        BigDecimal actualBalance = bankAccountInstance.getBalance(ACCOUNT_NUMBER);
        assertEquals(EXPECTED_AMT, actualBalance);
    }
}

Better approach

Mock it instead:

class BankAccountTest {
    @Mock
    private AccountService accountServiceMock;
    private BankAccount bankAccountInstance;
    private final Integer ACCOUNT_NUMBER = 123456789;
    
    @BeforeEach
    void init(){
        accountServiceMock = mock(AccountService.class); // Create a mock
        bankAccountInstance = new BankAccount(accountServiceMock); // Use the mock
        
        final BigDecimal EXPECTED_AMT = new BigDecimal("100.00");
        when(accountServiceMock.getBalance(ACCOUNT_NUMBER))
            .thenReturn(EXPECTED_AMT); // Define the behavior
    }
    
    @Test
    void getBalance_Should_Return_Correct_Balance(){
        final BigDecimal EXPECTED_AMT = new BigDecimal("100.00");
        BigDecimal actualBalance = bankAccountInstance.getBalance(ACCOUNT_NUMBER);
        assertEquals(EXPECTED_AMT, actualBalance);
    }
}

Write fast unit tests

Speed matters. Slow tests discourage developers from running them regularly, especially when making changes. That leads to bugs slipping through. And if tests are part of your CI/CD pipeline, slow ones hold up everything.

A unit test shouldn’t take more than 500ms to run. Anything slower needs a rethink.

Usually when a test is slow, it’s doing something it shouldn’t – like hitting the file system or an external resource.

Avoid logic in unit tests

Don’t put conditionals or loops in your tests. Adding logic increases the chance you’ll introduce bugs in the tests themselves. When a test fails, it should mean production code broke – not that your test has bugs.

If you really need logic, try splitting the test into multiple simpler tests instead.

Keep tests simple and readable. We talked about this earlier, but it’s worth repeating.

Bad example

Here’s a ShippingCalculator that charges $10 for the first kg, then $5 per additional kg:

public class ShippingCalculator {
    public static double BASE_FEE = 10.0;
    public static double PRICE_BY_ADDITIONAL_KG = 5.0;
    
    public static double calculateShippingFee(double weight){
        if(weight <= 1.0){
            return BASE_FEE;
        } else {
            return BASE_FEE + (weight - 1.0) * PRICE_BY_ADDITIONAL_KG;
        }
    }
}

This test has way too much logic. If it fails, good luck figuring out why. Plus it’s basically duplicating the production code:

@Test
void calculateShippingFee_Should_Return_The_Correct_Fee(){
    double[] shippingWeights = {0.5, 1.0, 1.1, 1.5, 10};
    
    for(double weight : shippingWeights) {
        if(weight <= 1.0){
            double fee = ShippingCalculator.calculateShippingFee(weight);
            assertEquals(ShippingCalculator.BASE_FEE, fee);
        } else {
            double fee = ShippingCalculator.calculateShippingFee(weight);
            final double EXPECTED_FEE = ShippingCalculator.BASE_FEE + 
                (weight - 1.0) * ShippingCalculator.PRICE_BY_ADDITIONAL_KG;
            assertEquals(EXPECTED_FEE, fee);
        }
    }
}

Better approach

Break it into smaller, logic-free tests. Each one checks a specific scenario and the expected outcome is crystal clear:

@Test
void calculateShippingFee_Should_Return_Base_Fee_When_Weight_Lower_Than_1Kg(){
    final double WEIGHT = 0.5;
    double fee = ShippingCalculator.calculateShippingFee(WEIGHT);
    assertEquals(ShippingCalculator.BASE_FEE, fee);
}

@Test
void calculateShippingFee_Should_Return_Base_Fee_When_Weight_Is_1Kg(){
    final double WEIGHT = 1.0;
    double fee = ShippingCalculator.calculateShippingFee(WEIGHT);
    assertEquals(ShippingCalculator.BASE_FEE, fee);
}

@Test
void calculateShippingFee_Should_Return_Correct_Fee_When_Weight_Greater_Than_1Kg(){
    final double WEIGHT = 1.1;
    final double EXPECTED_FEE = ShippingCalculator.BASE_FEE + 
        (0.1 * ShippingCalculator.PRICE_BY_ADDITIONAL_KG);
    double fee = ShippingCalculator.calculateShippingFee(WEIGHT);
    assertEquals(EXPECTED_FEE, fee);
}

Validate private methods using public methods

You can test private methods directly, but it’s better to cover them through public method tests.

Private methods don’t exist in isolation – they’re used by public methods. Test the public methods well and you’ll cover the private ones too.

Still, aim for high coverage of lines, branches, and yes, private methods.

Cover the maximum you can

Coverage measures how much of your production code gets executed during tests. The two main types are line coverage and branch coverage.

Line coverage counts how many statements your tests run. A statement is typically one line of code (comments don’t count). High line coverage means more code gets tested, which means fewer hidden bugs.

Branch coverage looks at how many different paths your tests take. Branches come from conditionals – if, for, while statements. Here’s the catch: 100% line coverage doesn’t guarantee 100% branch coverage.

Look at this example:

public String getPersonName(boolean isStudent) {
    Person person = null;
    if (isStudent) {
        person = new Person();
    }
    return person.getName();
}

This test hits every line:

@Test
void getPersonName_Returns_A_Name_when_Person_IsStudent(){
    assertNotNull(getPersonName(true));
}

But it only covers 50% of the branches. Call it with false and you get a NullPointerException. You need another test to properly cover both branches.

Tools like SonarQube can help track coverage. They integrate with your build tools and CI/CD pipeline.

Avoid magic values

Just like in production code, how you name and use variables in tests matters. Magic values – values with no context – confuse people. They’ll wonder why you picked that specific number instead of focusing on what the test actually does.

Make your intent clear. Use constants with descriptive names instead of random numbers.

Bad example

Where did 0.5 and 10.00 come from? Unless you know the production code well, you have no idea:

@Test
void calculateShippingFee_Should_Return_Base_Fee_When_Weight_Lower_Than_1Kg(){
    double fee = ShippingCalculator.calculateShippingFee(0.5);
    assertEquals(10.00, fee);
}

Better approach

Use constants with clear names. Don’t worry about long names – clarity beats brevity:

@Test
void calculateShippingFee_Should_Return_Base_Fee_When_Weight_Lower_Than_1Kg(){
    final double LESS_THAN_1KG_WEIGHT = 0.5;
    final double BASE_FEE = 10.00;
    
    double fee = ShippingCalculator.calculateShippingFee(LESS_THAN_1KG_WEIGHT);
    assertEquals(BASE_FEE, fee);
}

Test edge cases

Edge cases test the extreme limits of valid inputs. They’re important because bugs love to hide at the boundaries.

Here’s a Counter class that tracks how many people are in a store:

public class Counter {
    public final static int MAX_CAPACITY = 2000;
    private int count; // starts at zero
    
    public void increaseCount() throws MaximumCapacityReachedException {
        if(MAX_CAPACITY == count){
            throw new MaximumCapacityReachedException();
        }
        count++;
    }
    
    public void decreaseCount() throws MinimumCapacityReachedException {
        if(count == 0){
            throw new MinimumCapacityReachedException();
        }
        count--;
    }
    
    public int getCount() {
        return count;
    }
}

Bad example

These tests aren’t bad exactly, but they’re incomplete. They only check the happy path – normal incrementing, decrementing, and initialization:

class CounterTest {
    Counter counter;
    
    @BeforeEach
    void init(){
        counter = new Counter();
    }
    
    @Test
    void counter_Initialized_To_Zero(){
        final int INIT_VALUE = 0;
        assertEquals(INIT_VALUE, counter.getCount());
    }
    
    @Test
    void increaseCount_Increments_By_One_When_Invoked() 
            throws MaximumCapacityReachedException {
        final int EXPECTED_VALUE = 1;
        counter.increaseCount();
        assertEquals(EXPECTED_VALUE, counter.getCount());
    }
    
    @Test
    void decreaseCount_Decrements_By_One_When_Invoked() 
            throws MinimumCapacityReachedException, MaximumCapacityReachedException {
        counter.increaseCount(); // Set to 1 first
        final int EXPECTED_VALUE = 0;
        counter.decreaseCount();
        assertEquals(EXPECTED_VALUE, counter.getCount());
    }
}

But what about:

  • Incrementing when already at max capacity?
  • Decrementing when count is zero?
  • Other boundary conditions?

Better approach

Add tests for those edge cases. Here we’re just checking that the right exceptions get thrown:

@Test
void increaseCount_Throws_MaximumCapacityReachedException_When_Max_Val_Reached(){
    // Note: Add a setter if you don't have one
    counter.setCount(Counter.MAX_CAPACITY);
    
    assertThrows(
        MaximumCapacityReachedException.class,
        () -> counter.increaseCount()
    );
}

@Test
void decreaseCount_Throws_MinimumCapacityReachedException_When_Count_Is_Zero(){
    // Counter starts at zero when created
    assertThrows(
        MinimumCapacityReachedException.class,
        () -> counter.decreaseCount()
    );
}

Learn your testing framework

Testing frameworks pack a lot of useful features, assertions, and utilities. The more you know about your framework, the better your tests will be. They’ll be cleaner, easier to read, and more reliable.

Bad example

Manually checking for exceptions like this? If you’re still doing this with JUnit, you’re missing out on better ways:

@Test
void increaseCount_Throws_MaximumCapacityReachedException_When_Max_Val_Reached(){
    counter.setCount(Counter.MAX_CAPACITY);
    boolean thrownException = false;
    
    try {
        counter.increaseCount();
    } catch(MaximumCapacityReachedException e){
        thrownException = true;
    }
    
    assertTrue(thrownException);
}

Better approach

Use assertThrows. Clean, simple, one line:

@Test
void decreaseCount_Throws_MinimumCapacityReachedException_When_Count_Is_Zero(){
    assertThrows(
        MinimumCapacityReachedException.class,
        () -> counter.decreaseCount()
    );
}

The bottom line

Unit tests are your safety net for maintaining clean, reliable code. Good tests give you confidence that your code does what it’s supposed to do and catches regressions when you make changes. That’s why following these best practices matters – they help you write tests that are clean, clear, and actually useful.

Also, make unit tests part of your CI process if you’re running one. A solid CI setup runs tests automatically and gives your team important metrics – code coverage, how much new code is covered, test count, performance stats, all that good stuff.

Leave a Reply

Your email address will not be published. Required fields are marked *