Skip to content
academia.sh

Lesson 15 / 19

Layer Tests

The difference between a slice test and a full context test is not an adjective but a number; the same container passing through two setups is placed side by side by counting how many components each one builds. The limiting measurement shows that even when a component's setup annotation left outside the slice never runs at all, the slice test's result never changes, and the test still passes.

Published

In this lesson

  • Compares, by running it, how many components a slice test and a full context test build when passing through the same container
  • Shows that a setup annotation (`@Setup`) runs only on a component built through the IoC container, and never runs on the same class built by hand
  • Measures that the slice test's result never changes even when a component left outside the slice never has its annotation applied
Contents

The previous lesson measured the claims a token carries and the source of validation, through a single call. This lesson steps back and enlarges the question: how many components does a container actually bring up, and which of these does a given test actually build? Two test setups are modeled — a slice test and a full context test — and the difference between them is measured not by an adjective but by a number: the number of components the container builds. How many components does the slice test require, how many does the full context test build, and does the slice test notice when something outside the slice breaks? The scope and purpose of layer tests were built in the Integration, Contract and End-to-End Testing and the Build, Testing and the Ecosystem courses and are not repeated here; this lesson only shows, by number, how different the two setups are from the container’s point of view.

The word “layer” here points to the layers in the Web and Data Layer topic — controller, service, data access. A slice test usually targets only one of these layers and replaces the layer above or below it with a fake implementation; this lesson shows how that substitution is done through the IoC container and how the result can be counted.

Placing Two Setups Side by Side: Component Count

The modeled application is built from four parts: a data store interface and its real implementation, a service interface and its real implementation, a controller depending on that service, and an independent logger with no relation to the service at all. The same Container class is the same mechanism as the IoC container built in the Core Concepts topic — it resolves its constructor dependencies by reflection, calling @Setup-marked methods after construction — here only the part used in this lesson is rewritten.

// Setup.java — annotation type marking a method to be called after construction (helper source, no main)
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Setup { }
// Container.java — small IoC container that resolves its constructor dependencies by reflection and tracks the count of every component it builds (helper source, no main)
import java.lang.reflect.*;
import java.util.*;

class Container {
    final Map<Class<?>, Class<?>> bindings = new HashMap<>();
    final List<Object> built = new ArrayList<>();

    Container bind(Class<?> iface, Class<?> impl) { bindings.put(iface, impl); return this; }

    @SuppressWarnings("unchecked")
    <T> T build(Class<T> type) throws Exception {
        Class<?> real = type.isInterface() ? bindings.get(type) : type;
        Constructor<?> ctor = real.getDeclaredConstructors()[0];
        Class<?>[] paramTypes = ctor.getParameterTypes();
        Object[] arguments = new Object[paramTypes.length];
        for (int i = 0; i < arguments.length; i++) arguments[i] = build(paramTypes[i]);
        ctor.setAccessible(true);
        Object instance = ctor.newInstance(arguments);
        for (Method m : real.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Setup.class)) { m.setAccessible(true); m.invoke(instance); }
        }
        built.add(instance);
        return (T) instance;
    }

    static void show(String label, Object value) {
        System.out.printf("%-52s: %s%n", label, value);
    }
}

The application’s four components sit in separate files. RealService carries a setup hook — the status field starts at not-ready, and turns to ready once the @Setup-marked prepare method runs. FakeService, by contrast, is the fake the slice test holds in place of the real service — it carries no setup hook at all, because there is no need for one. Its constructor is also empty: RealService requires a DataStore; FakeService requires nothing. This difference also shrinks the slice test’s component count — building FakeService never triggers InMemoryStore at all, because there is no dependency to resolve.

// DataStore.java — the interface for data access (helper source, no main)
interface DataStore { String read(); }
// InMemoryStore.java — the real implementation of the data store (helper source, no main)
class InMemoryStore implements DataStore {
    public String read() { return "data"; }
}
// Service.java — the interface for the business logic (helper source, no main)
interface Service { String process(); }
// RealService.java — the real implementation of the service; its status stays 'not-ready' until the setup hook runs (helper source, no main)
class RealService implements Service {
    final DataStore store;
    String status = "not-ready";

    RealService(DataStore store) { this.store = store; }

    @Setup void prepare() { status = "ready"; }

    public String process() { return status + ":" + store.read(); }
}
// FakeService.java — the fake implementation held by the slice test, carrying no setup hook at all (helper source, no main)
class FakeService implements Service {
    public String process() { return "fake-data"; }
}
// Controller.java — depends on the service, the slice test's actual target (helper source, no main)
class Controller {
    final Service service;
    Controller(Service service) { this.service = service; }
    String respond() { return service.process(); }
}
// Logger.java — has no dependency on the service at all, an independent part of the full context (helper source, no main)
class Logger {
    String status() { return "ready"; }
}

Two separate containers are built: sliceContainer binds Service to FakeService; fullContainer binds the same interface to RealService. Both ask for the same Controller, both call the same build method — the only difference is in the binding table.

// ComponentCount.java — with the same Controller, how many components are built when requested from two different containers
public class ComponentCount {
    public static void main(String[] args) throws Exception {
        Container sliceContainer = new Container()
                .bind(Service.class, FakeService.class)
                .bind(DataStore.class, InMemoryStore.class);
        Controller sliceController = sliceContainer.build(Controller.class);

        Container fullContainer = new Container()
                .bind(Service.class, RealService.class)
                .bind(DataStore.class, InMemoryStore.class);
        Controller fullController = fullContainer.build(Controller.class);
        fullContainer.build(Logger.class);

        Container.show("slice test, number of components built", sliceContainer.built.size());
        Container.show("full context test, number of components built", fullContainer.built.size());
        Container.show("slice test, Controller.respond()", sliceController.respond());
        Container.show("full context test, Controller.respond()", fullController.respond());
    }
}
slice test, number of components built              : 2
full context test, number of components built       : 4
slice test, Controller.respond()                    : fake-data
full context test, Controller.respond()             : ready:data

ST12. Both the slice test and the full context test pass through the same Container class; the difference between them is measured not in the structure of the code but in the number of components the container builds. ST13. The slice test even builds the direct dependency of the Controller it tests through the IoC container, using a fake implementation — FakeService also goes into the built list — but the total component count is still lower than the full context test’s: two rather than four. ST14. The full context test builds the entire dependency chain (RealService, InMemoryStore) as well as independent components that never enter the chain at all (Logger); even a component the Controller has no need for becomes part of the full context, because the full context test brings up the entire application.

The build method itself does not know at all which test is behind which setup; all it knows is the binding table and the constructor signatures. The same method, on seeing a Service parameter, looks at the bindings map and builds whatever it finds there — FakeService for sliceContainer, RealService for fullContainer. The difference is in the test’s intent, not in the code itself. This is also why the component count matters: the slice test runs fast because it builds few components, since every component built by reflection carries a constructor resolution and, if needed, a setup hook call; the full context test does all of this, and a component count that quadruples means roughly a quadrupled build cost.

An Annotation Left Outside the Slice

RealService’s setup hook runs only when the IoC container builds it. If the same class is constructed by hand with new, the @Setup-marked prepare method never runs at all — this is the same limiting reading built in the Core Concepts topic, measured again here on a service class.

// OutsideTheSlice.java — RealService's setup hook never runs at all outside the container; the slice test is entirely independent of it
public class OutsideTheSlice {
    public static void main(String[] args) throws Exception {
        Container sliceContainer = new Container()
                .bind(Service.class, FakeService.class)
                .bind(DataStore.class, InMemoryStore.class);
        Controller sliceController = sliceContainer.build(Controller.class);

        Container fullContainer = new Container()
                .bind(Service.class, RealService.class)
                .bind(DataStore.class, InMemoryStore.class);

        RealService manuallyBuilt = new RealService(new InMemoryStore());
        Service builtByContainer = fullContainer.build(Service.class);

        Container.show("RealService, manually built, @Setup never ran", manuallyBuilt.process());
        Container.show("RealService, built via container, @Setup ran", builtByContainer.process());
        Container.show("slice test, Controller.respond() (independent of RealService)", sliceController.respond());
    }
}
RealService, manually built, @Setup never ran       : not-ready:data
RealService, built via container, @Setup ran        : ready:data
slice test, Controller.respond() (independent of RealService): fake-data

The first two lines show two different ways of constructing the same class: built by hand it is not-ready:data, built through the container it is ready:data — the annotation applies only to the second. ST15. A setup annotation is applied only when the IoC container builds it; the same class built by hand never sees the annotation at all, because the party that reads and invokes the annotation is the container itself. The third line is the lesson’s real point: the slice test’s result — fake-data — is entirely independent of whether RealService was built by hand or through the container, because the slice test never sees RealService at all. Even if RealService’s setup hook broke, was removed, or the class itself was deleted entirely, the slice test would never know.

ST16. Even when a component left outside the slice never has its annotation applied, the slice test’s result never changes, and the test still passes — this is the lesson’s limiting measurement. The slice test validates whether Controller calls Service correctly; it never validates whether RealService‘s setup hook works at all, because that lies outside its verification surface. Building fewer components means verifying less — the slice test is fast and focused, but every annotation, every setup hook belonging to RealService sits entirely outside the slice’s field of view. The common definition’s defect classification covers this case exactly: from the slice test’s point of view, RealService‘s broken setup hook is neither an error nor silent — it is entirely ineffective from the test’s own perspective, because the test has no awareness that the hook even exists. The only thing that closes this gap is the full context test, because only that setup actually brings RealService up and can check whether its hook ran.

The two test types do not substitute for one another; they answer different questions:

Question Slice test Full context test
Number of components built 2 4
Is it verified that Controller calls Service correctly yes yes
Is RealService’s setup hook verified no yes
Is Logger’s existence verified no yes

The table comes down to a single conclusion: the slice test’s cheapness comes with a gap, and this gap is not random — every component the container never built in that test sits outside the slice’s field of view. If a codebase is protected only by slice tests, the real connection between components is never actually exercised by running it; only the full context test, at a cost proportional to the number of components it builds, can close that gap.

In practice a codebase keeps both, and not for the same purpose: slice tests run often on every change, because building few components makes them fast, and they quickly validate the behavior of a single layer such as Controller; full context tests run less often, because building more components makes them slower, but in exchange they test the real connection between components. The number measured in this lesson — two in the slice, four in the full context — is on a small example; in a real application this ratio can range from the tens into the hundreds, and as the gap grows, so does the surface the slice test never validates.

Summary

  • The slice test and the full context test pass through the same container; the difference between them is measured by the number of components the container builds.
  • The slice test can build even the direct dependency of the class it tests with a fake implementation; even so, the total component count stays lower than the full context test’s.
  • The full context test builds the entire dependency chain as well as independent components that never enter the chain at all.
  • A setup annotation is applied only when the IoC container builds it; the same class built by hand never sees the annotation at all.
  • Even when a component left outside the slice never has its annotation run, the slice test’s result never changes, and the test still passes; building fewer components means verifying less.

Next Step

This lesson counted which components were built; the next lesson asks about something never built, never written at all. There are endpoints that ask from the outside whether an application is in working order, and most of these endpoints never appear in the source at all — no class, no method, no annotation. The next lesson measures how many of these endpoints exist without ever being written, and which information gets given out to the outside.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close