Index

Testing Functional Code

Java Functional Programming

16.1 Unit Testing Lambdas and Functional Interfaces

Testing lambda expressions and functional interfaces in Java poses some unique challenges due to their concise syntax and the fact that they often represent small, stateless functions embedded inside larger workflows. However, by applying best practices and leveraging modern testing frameworks like JUnit, you can write clear, maintainable tests for your functional code.

Challenges in Testing Lambdas

Best Practices for Testing Functional Code

Testing Simple Functional Interfaces

Here are examples using JUnit 5 to test common functional interfaces:

Testing a Predicate

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.util.function.Predicate;

public class PredicateTest {

    Predicate<String> isLongerThan5 = s -> s.length() > 5;

    @Test
    void testIsLongerThan5() {
        assertTrue(isLongerThan5.test("functional"));
        assertFalse(isLongerThan5.test("java"));
    }
}

Testing a Function

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.util.function.Function;

public class FunctionTest {

    Function<String, Integer> stringLength = String::length;

    @Test
    void testStringLength() {
        assertEquals(4, stringLength.apply("test"));
        assertEquals(0, stringLength.apply(""));
    }
}

Testing a Consumer with Side Effects

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.util.function.Consumer;
import java.util.ArrayList;
import java.util.List;

public class ConsumerTest {

    @Test
    void testConsumerAddsToList() {
        List<String> list = new ArrayList<>();
        Consumer<String> addToList = list::add;

        addToList.accept("hello");
        addToList.accept("world");

        assertEquals(2, list.size());
        assertEquals("hello", list.get(0));
    }
}

Verifying Behavior with Mocks

For lambdas that invoke external dependencies, use mocking frameworks to verify interactions:

import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import java.util.function.Consumer;

public class MockConsumerTest {

    @Test
    void testConsumerInvokesDependency() {
        Consumer<String> mockConsumer = mock(Consumer.class);

        mockConsumer.accept("data");

        verify(mockConsumer).accept("data");
    }
}

Summary

Testing lambdas and functional interfaces requires isolating the logic, handling side effects carefully, and using clear assertions. By extracting lambdas into named functions and applying standard JUnit techniques, you can ensure your functional code is robust, testable, and maintainable.

Index

16.2 Using Mocks and Stubs in Functional Context

When testing functional code, especially where side effects or external dependencies occur, mocks and stubs become essential tools. Functional programming encourages pure functions, but real-world applications often interact with external systems (databases, APIs) or have stateful operations. Mocks and stubs help isolate the functional logic by simulating these interactions, making tests predictable, repeatable, and focused.

Why Use Mocks and Stubs?

Using Mockito to Mock Functional Interfaces

Mockito is a popular mocking framework in Java that seamlessly integrates with lambdas and functional interfaces.

Mocking a Supplier

A Supplier<T> returns a value without input. Mocking allows specifying what the supplier returns:

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import java.util.function.Supplier;

public class SupplierMockTest {

    @Test
    void testMockSupplier() {
        Supplier<String> supplier = mock(Supplier.class);
        when(supplier.get()).thenReturn("mocked value");

        String result = supplier.get();

        assertEquals("mocked value", result);
        verify(supplier).get();
    }
}

Mocking a Consumer

A Consumer<T> performs an action with side effects. Mocks verify that expected inputs are consumed:

import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import java.util.function.Consumer;

public class ConsumerMockTest {

    @Test
    void testMockConsumer() {
        Consumer<String> consumer = mock(Consumer.class);

        consumer.accept("test input");

        verify(consumer).accept("test input");
    }
}

Mocking Callbacks and Handling Asynchrony

Functional code often uses callbacks invoked asynchronously. You can simulate asynchronous behavior by invoking the callback mock manually or with executors.

import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import java.util.function.Consumer;

public class AsyncCallbackTest {

    interface AsyncService {
        void fetchData(Consumer<String> callback);
    }

    @Test
    void testAsyncCallback() {
        AsyncService service = mock(AsyncService.class);
        Consumer<String> callback = mock(Consumer.class);

        doAnswer(invocation -> {
            Consumer<String> cb = invocation.getArgument(0);
            cb.accept("async result");  // Simulate async callback
            return null;
        }).when(service).fetchData(any());

        service.fetchData(callback);

        verify(callback).accept("async result");
    }
}

Strategies for Pure and Predictable Tests

Summary

Mocks and stubs are vital in functional programming tests where side effects or dependencies exist. Mockito’s ability to mock lambdas and functional interfaces lets you simulate suppliers, consumers, and callbacks effortlessly. By isolating effects and verifying interactions, your tests stay clean, pure, and predictable, ensuring functional logic is robust and reliable.

Index

16.3 Example: Testing Stream Pipelines

Testing stream pipelines involves verifying that a sequence of operations—such as filtering, mapping, and collecting—produces the expected output for given inputs. The goal is to isolate the stream logic and assert outcomes clearly, including edge cases like empty or null inputs.

Example: Stream Pipeline to Process Employee Names

Suppose we have a method that processes a list of employees, filters those older than 25, converts their names to uppercase, and collects them into a list.

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

import java.util.*;
import java.util.stream.*;

public class StreamPipelineTest {

    static class Employee {
        String name;
        int age;

        Employee(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }

    /**
     * Processes employees by filtering age > 25,
     * mapping names to uppercase, and collecting as list.
     */
    public List<String> processEmployees(List<Employee> employees) {
        if (employees == null) {
            return Collections.emptyList(); // Defensive null handling
        }

        return employees.stream()
                .filter(e -> e.age > 25)                   // Filter employees older than 25
                .map(e -> e.name.toUpperCase())             // Convert names to uppercase
                .collect(Collectors.toList());              // Collect to list
    }

    @Test
    void testProcessEmployees_withValidData() {
        List<Employee> input = Arrays.asList(
                new Employee("Alice", 30),
                new Employee("Bob", 20),
                new Employee("Charlie", 28)
        );

        List<String> expected = Arrays.asList("ALICE", "CHARLIE");
        List<String> result = processEmployees(input);

        assertEquals(expected, result);
    }

    @Test
    void testProcessEmployees_withEmptyList() {
        List<Employee> input = Collections.emptyList();
        List<String> result = processEmployees(input);

        assertTrue(result.isEmpty());
    }

    @Test
    void testProcessEmployees_withNullInput() {
        List<String> result = processEmployees(null);
        assertTrue(result.isEmpty());
    }

    @Test
    void testProcessEmployees_allFilteredOut() {
        List<Employee> input = Arrays.asList(
                new Employee("Dave", 22),
                new Employee("Eve", 18)
        );

        List<String> result = processEmployees(input);
        assertTrue(result.isEmpty());
    }
}

Explanation

Summary

Unit testing stream pipelines is straightforward when you isolate the pipeline into a method and provide diverse input scenarios. Handling edge cases like empty or null inputs prevents runtime errors and ensures robustness. Clear, assertive tests verify that filtering, mapping, and collecting logic behaves as expected, maintaining code quality in functional Java applications.

Index