Learnitweb

Testing Business Logic with Dependencies and Stubs

In our previous tutorial, we built a simple business logic component using JUnit 5 and observed how real-world applications often involve dependencies like external services. This step is about unit testing such dependencies—first using a stub before we learn how to use Mockito in later lessons.

1. From Simple Input to Service-Driven Logic

Previously, we passed data directly to the method. However, in real-world applications:

  • Business logic classes often depend on other services to fetch or process data.
  • These services are typically injected using dependency injection (constructor or setter).
  • We need to simulate those services during testing, without relying on external systems like databases.

2. Defining a Data Service Interface

Step 1: Create a SomeDataService Interface

package com.learnitweb.unittesting.data;

public interface SomeDataService {
    int[] retrieveAllData();
}

3. Refactor Business Logic to Use the Service

Step 2: Modify SomeBusinessImpl to Use the Data Service

package com.in28minutes.unittesting.business;

import com.in28minutes.unittesting.data.SomeDataService;

public class SomeBusinessImpl {
    private SomeDataService dataService;

    public void setDataService(SomeDataService dataService) {
        this.dataService = dataService;
    }

    public int calculateSumUsingDataService() {
        int[] data = dataService.retrieveAllData();
        int sum = 0;
        for (int value : data) {
            sum += value;
        }
        return sum;
    }
}

This class now depends on SomeDataService. We’ll need to simulate (stub/mock) this service in unit tests.

4. Challenge: Unit Testing with a Dependency

If you try to test calculateSumUsingDataService() without setting the dataService, you’ll get a NullPointerException.

int[] data = dataService.retrieveAllData(); // dataService is null!

We need a way to provide a controlled, test-specific version of SomeDataService.

5. Writing a Unit Test Using a Stub

A stub is a fake implementation of an interface with hardcoded behavior, just enough for the test case.

Step 3: Create a Stub for SomeDataService

package com.learnitweb.unittesting.business;

import com.learnitweb.unittesting.data.SomeDataService;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class SomeBusinessStubTest {

    class SomeDataServiceStub implements SomeDataService {
        @Override
        public int[] retrieveAllData() {
            return new int[]{1, 2, 3};
        }
    }

    @Test
    void calculateSumUsingDataService_basic() {
        SomeBusinessImpl business = new SomeBusinessImpl();
        business.setDataService(new SomeDataServiceStub());
        int result = business.calculateSumUsingDataService();
        assertEquals(6, result);
    }
}

Explanation:

  • The SomeDataServiceStub class overrides retrieveAllData() and returns a fixed array.
  • We inject this stub using the setDataService() method.
  • The test checks if the result is correct based on stubbed data.

6. Stub-Based Testing vs Real Services

6.1 Why Use a Stub?

  • Isolates the unit test from external systems like databases.
  • Makes the test repeatable and predictable.
  • Requires no external setup (e.g., no test database).

6.2 Limitations of Stubs:

  • You have to manually create a new stub for each test scenario.
  • If the interface has many methods, stubs become hard to maintain.
  • Not flexible or reusable across tests.

7. Extend the Stub Test

You’re encouraged to extend the stub test with more scenarios. For example:

class SomeDataServiceEmptyStub implements SomeDataService {
    public int[] retrieveAllData() {
        return new int[] {};
    }
}

class SomeDataServiceSingleValueStub implements SomeDataService {
    public int[] retrieveAllData() {
        return new int[] {5};
    }
}

Write additional tests like:

@Test
void calculateSumUsingDataService_empty() {
    SomeBusinessImpl business = new SomeBusinessImpl();
    business.setDataService(new SomeDataServiceEmptyStub());
    assertEquals(0, business.calculateSumUsingDataService());
}

@Test
void calculateSumUsingDataService_oneValue() {
    SomeBusinessImpl business = new SomeBusinessImpl();
    business.setDataService(new SomeDataServiceSingleValueStub());
    assertEquals(5, business.calculateSumUsingDataService());
}

8. The Problem with Multiple Stubs

While stub-based testing works, it quickly becomes unmaintainable. Here’s why:

8.1 You Need a Separate Stub for Each Test Scenario

Each scenario requires a custom class:

  • SomeDataServiceStub
  • SomeDataServiceEmptyStub
  • SomeDataServiceSingleValueStub

If you have 10+ test scenarios, that’s 10+ stub classes to manage.

8.2 Interface Changes Cause Compilation Errors

If you modify the interface (SomeDataService) and add a new method:

int[] retrieveSpecificData(String filter);

All stub classes will break until you implement this new method in each stub—even if the method is irrelevant to that specific test.

8.3 Hard to Understand and Track Stub Behavior

When reviewing a test, it’s not obvious what data a stub returns unless you open the class. You can try naming stubs more descriptively, but:

  • Names can get too long or ambiguous.
  • As complexity grows, it’s harder to describe each stub meaningfully.

9. The Solution: Mockito

Mockito addresses all the issues with stubs:

  • You don’t create manual stub classes.
  • You define behavior inline in your test.
  • You can change return values dynamically.
  • No compilation errors when interfaces evolve (as mocks can ignore irrelevant methods).

Here’s a teaser of what’s possible with Mockito:

SomeDataService dataServiceMock = mock(SomeDataService.class);
when(dataServiceMock.retrieveAllData()).thenReturn(new int[]{1, 2, 3});