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.