Lesson 08 / 12
Integration Tests
The supplier mocked in the unit setup is put back on the runtime class path here with its real implementation, together with a second type the real implementation depends on; the type count the path carries rises from one to two. The same test body runs in two setups: in the unit setup a limit violation passes silently, in the integration setup the same request gives a run failure.
Published
In this lesson
- Counts that the integration setup puts real types back on the runtime class path and that the path's content grows
- Runs the same test body against unit and integration setups and compares where the defect falls
- Shows that a limit violation that stays silent in the unit setup turns into a run failure in integration
Contents
In the previous lesson, the thing that was shared was an object a test body built itself; the
code under test itself never changed. This lesson moves shared state outside the code
under test: the Supplier dependency mocked in the second lesson comes back here with its
real implementation, and a second type the real implementation itself depends on ends up added
to the class path. The question is: when the same test body runs against a class path whose
content is growing, where does a divergence that previously stayed silent land?
This fourth lesson’s axis proceeds through the same name Supplier as the second lesson, but
it seeks an answer to a different question. The second lesson asked the “same name, two
bodies” question and measured when one type taking another’s place stays silent, and when it
gives a run failure. This lesson goes one step behind that question: when one of the
implementations carries a dependency the other does not, how does the class path itself
change, and which defect does that change make visible?
The subject area of integration testing — test environment management, mocking external services — was established in the M21/K03 Integration, Contract and End-to-End Testing course and is not rebuilt here. The one question this lesson adds is the relationship between the size of the class path the test sees and where the defect falls.
The difference between unit and integration setups is usually explained through setup time or access to external systems — one fast and isolated, the other slow and real. This lesson reads the same difference through the class path: the real difference between the two setups is the set of types the test never sees at compile time but encounters at run time. What makes a setup “unit” or “integration” is not a label, it is which types are put on the class path in that particular run.
The Same Dependency, With Its Real Implementation
- TE16. The external system here is built as an ordinary Java class named
Inventory, modeling a real-world stock limit. No product, driver or protocol name is written; no real network call, file system access or external process is made either. - TE17. “The type count the path carries” here is a count of compiled
.classfiles — the same measuring method the first lesson established.
In the second lesson, a type named Supplier existed with a production implementation that
fulfilled the entire requested quantity and a fake implementation that fulfilled one unit less
than requested; the test was not bound to either at compile time, it only looked up the name
through reflection. This lesson reuses the same name, but this time builds the real
implementation not on its own, but together with a limit check it depends on:
Inventory.checkLimit throws an exception when the requested quantity exceeds 100. The fake
implementation in the unit setup carries no such check — it fulfills every request silently.
The difference between these two implementations is a deliberate design decision. The unit
setup carries no limit check, because the unit test’s purpose is to test the flow of the code
that calls Supplier — the limit itself is Inventory’s responsibility, and Inventory’s own
test is written separately. The integration setup, by running the two types together, tests
the real interaction between them: does Supplier really pass the call on to Inventory,
does the exception Inventory throws really reach the caller? Neither of these questions can
be asked in the unit setup, because Inventory is not there at all.
// Growing.java — integration setup puts real types back onto the runtime class path, the path's type count grows
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
public class Growing {
static Path write(Path dir, String name, String source) throws IOException {
Files.createDirectories(dir);
Path p = dir.resolve(name + ".java");
Files.writeString(p, source);
return p;
}
static boolean compile(Path output, List<Path> classPath, List<Path> sources) throws IOException {
Files.createDirectories(output);
List<String> arg = new ArrayList<>(List.of("-d", output.toString()));
if (!classPath.isEmpty()) {
StringJoiner sj = new StringJoiner(File.pathSeparator);
for (Path p : classPath) sj.add(p.toString());
arg.add("-classpath");
arg.add(sj.toString());
}
for (Path k : sources) arg.add(k.toString());
ToolProvider compiler = ToolProvider.findFirst("javac").orElseThrow();
StringWriter silentOutput = new StringWriter();
return compiler.run(new PrintWriter(silentOutput), new PrintWriter(silentOutput),
arg.toArray(new String[0])) == 0;
}
static String run(List<Path> classPath, String mainClass) {
URL[] url = new URL[classPath.size()];
try {
for (int i = 0; i < url.length; i++) url[i] = classPath.get(i).toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(url, Growing.class.getClassLoader().getPlatformClassLoader())) {
Class<?> cls = Class.forName(mainClass, true, loader);
cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
return "ok";
}
} catch (InvocationTargetException e) {
return e.getCause().getClass().getSimpleName();
} catch (Throwable t) {
return t.getClass().getSimpleName();
}
}
static long typeCount(Path dir) throws IOException {
try (var walk = Files.walk(dir)) {
return walk.filter(p -> p.toString().endsWith(".class")).count();
}
}
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("growing");
Path source = root.resolve("source");
Path testDir = root.resolve("test"), unitDir = root.resolve("unit"), integrationDir = root.resolve("integration");
Path sTest = write(source, "IntegrationTest",
"import java.lang.reflect.InvocationTargetException;\n"
+ "import java.lang.reflect.Method;\n"
+ "public class IntegrationTest {\n"
+ " public static void main(String[] a) throws Throwable {\n"
+ " Class<?> c = Class.forName(\"Supplier\");\n"
+ " Object obj = c.getDeclaredConstructor().newInstance();\n"
+ " Method m = c.getMethod(\"fill\", int.class);\n"
+ " int[] requests = {30, 150};\n"
+ " for (int request : requests) {\n"
+ " try {\n"
+ " int result = (int) m.invoke(obj, request);\n"
+ " System.out.println(\"request \" + request + \" -> result \" + result);\n"
+ " } catch (InvocationTargetException e) {\n"
+ " throw e.getCause();\n"
+ " }\n"
+ " }\n"
+ " System.out.println(\"all requests completed\");\n"
+ " }\n"
+ "}\n");
Path sUnit = write(source.resolve("unit"), "Supplier",
"public class Supplier {\n"
+ " public int fill(int requested) { return requested; }\n"
+ "}\n");
Path integrationSourceDir = source.resolve("integration");
Path sInventory = write(integrationSourceDir, "Inventory",
"public class Inventory {\n"
+ " static void checkLimit(int requested) {\n"
+ " if (requested > 100) throw new IllegalStateException(\"stock limit exceeded\");\n"
+ " }\n"
+ "}\n");
Path sReal = write(integrationSourceDir, "Supplier",
"public class Supplier {\n"
+ " public int fill(int requested) { Inventory.checkLimit(requested); return requested; }\n"
+ "}\n");
compile(testDir, List.of(), List.of(sTest));
compile(unitDir, List.of(), List.of(sUnit));
compile(integrationDir, List.of(), List.of(sInventory, sReal));
System.out.println("-- type count on the path --");
System.out.println("unit setup : " + typeCount(unitDir) + " types");
System.out.println("integration setup : " + typeCount(integrationDir) + " types");
System.out.println("-- same test body, two setups --");
System.out.println("unit run result : " + run(List.of(testDir, unitDir), "IntegrationTest"));
System.out.println("integration run result: " + run(List.of(testDir, integrationDir), "IntegrationTest"));
}
}
-- type count on the path -- unit setup : 1 types integration setup : 2 types -- same test body, two setups -- request 30 -> result 30 request 150 -> result 150 all requests completed unit run result : ok request 30 -> result 30 integration run result: IllegalStateException
The unit setup carries a single .class file: the fake Supplier, dependent on nothing else.
The integration setup carries two .class files: the real Supplier and the Inventory it
calls. IntegrationTest is, for the third time, the same file, the same bytecode — the only
thing that changes is which directories are given to the class path. The path’s content grows
because the real implementation brings with it a dependency the fake implementation never
carried at all.
This growth proceeds in a different direction from the growth counted in the first lesson.
There, the test class path carried two types the production class path never knew at
all — the framework and the test itself, both outside the production code. Here, the side
that grows is the production side: Inventory is a real part of the production code, but
it never entered the class path in the unit setup. A class path growing does not mean a single
thing — sometimes it grows because the test carries its own tools, sometimes because it puts a
real part of production back in. Both are measured with the same counting method — counting
compiled .class files — but what they point to is separate.
The Same Test Body, Two Setups, Two Defect Classes
- TE18. The same request sequence —
30and150— is run against both the unit and integration setups, without recompiling the test. - TE19. The test itself carries no assertion here, it only makes the calls and prints the result. This sharpens the “silent” result even further: even if an assertion had been added, that assertion would only look at the return value, and would stay unaware of the limit violation.
- TE20. The exception the real implementation throws is an unchecked exception. The
reflective call wraps it inside
InvocationTargetException; the test source unwraps this wrapper and rethrows the actual cause, otherwise the harness would only see the wrapper class’s name.
Both requests are sent in the same order: 30 first, then 150. In the unit setup both return without a problem — the fake implementation recognizes no limit, the line “request 150 -> result 150” is written the same way as the others, and the program ends by printing “all requests completed.” The fact that a limit exists appears nowhere; the test passed because the test was already unaware of the limit.
In the integration setup, the first request (30) returns the same way, but the second request
(150) exceeds Inventory‘s limit and Supplier.fill throws an exception. This exception is
caught and rethrown in the test’s own source, and then the helper that runs it catches it and
reports its name: IllegalStateException. The same two requests, in the same order, the same
test file — the only difference is which Supplier and which extra dependency stand on the
class path.
This exception’s exit from the test’s own source is two-layered, and this lesson deliberately
keeps these two layers apart. Supplier.fill throws an IllegalStateException; because the
test triggers this through reflection with the call m.invoke(obj, request), reflection
itself first wraps this exception inside an InvocationTargetException. The test source
catches this wrapper with catch (InvocationTargetException e) and says throw e.getCause(),
exposing the actual exception and rethrowing it. Had this step been skipped, the helper that
runs it would only report the name “InvocationTargetException” — a name that is correct but
says little. Unwrapping the wrapper in the test’s own source lets the reported name be
IllegalStateException, keeping what went wrong one step more visible.
These two results are the test topic’s counterpart to the course’s limiting reading: putting the real type back does not make the test more correct, it only makes a different defect class visible. The unit setup was already a valid setup — fast, isolated, independent of whether a real limit exists. The integration setup does not invalidate it, it adds a layer on top: it also brings the constraints the real implementation carries onto the class path. The divergence that stayed “silent” in the unit setup — a request exceeding the limit passing unnoticed — turns here into an exception, that is, a “run” defect. Both processed the same two requests, in the same order; the difference between them was not in the assertions the test wrote, but in the content of the class path the test ran on. Both setups can be correctly built; what separates them is not the response they give to the same input, but which reality that input is tested against.
The practical consequence of this distinction is that there is no single answer to which
setup is “enough.” The unit setup quickly confirms whether a request like 150 is processed in
the normal flow; it keeps working even when Inventory itself changes or does not exist at
all, because it is not bound to it in any way. The integration setup, on the other hand,
confirms that Inventory really is called and really does enforce a limit, but the price of
this is including a second type on the class path and becoming dependent on that type’s
behavior. Which question is being asked determines which setup is chosen; both test different
realities that carry the same name Supplier.
It is worth stressing that the two setups do not replace each other, and both remain
necessary. The unit setup is still valuable: if a heavy or slow dependency like Inventory
were really built on every run, testing the logic of the code that calls Supplier would
carry the cost of building that dependency every single time. The unit setup eliminates this
cost entirely, and in exchange only tests a world where Inventory does not exist. The
integration setup, on the other hand, accepts that cost and actually runs how the two types
behave together. A test suite usually keeps both together: a large number of unit setups run
quickly on every change, while a smaller number of integration setups confirm less often that
the real dependencies work together. What this lesson shows is that the two layers do not
test the same behavior twice — each carries a defect class the other cannot see.
At this point the class path measure turns into a concrete decision tool: instead of arguing
over whether a setup is “unit” or “integration,” it is enough to look at which types enter
that setup’s class path. If Inventory is on the class path, Inventory’s behavior is being
tested; if it is not, it is not being tested — the difference between the two is not a
statement of intent, it is a compiled fact, and it can be read directly with a count like
typeCount.
This measure also carries a warning: every real type added to the class path brings the world
the test sees a little closer to the world production sees, but never makes the two fully
equal. Inventory itself could also have depended on something else — it was not built that
way in this lesson, the limit itself is complete on its own. In a real system, the integration
setup also stops at its own boundary; past a certain point, bringing up the entire system under
test is the job of end-to-end testing, not integration testing, and that boundary is M21/K03’s
subject.
Summary
- The unit setup carried a single type, the integration setup carried a second type the real implementation depends on along with it; the path’s content rose from one to two.
- The same compiled test body ran, with the same two requests, against two separate runtime class paths without being recompiled.
- In the unit setup, the request exceeding the limit passed silently and the test said “completed”; no warning appeared anywhere.
- In the integration setup, the same request threw
IllegalStateException; the defect was no longer silent, it was a run failure. - Putting the real implementation back did not make the test more correct, it only made visible a defect class that was invisible in the unit setup.
Next Step
In this lesson, the test sent a number and read the returned value; the assertion lived inside the body, in a form that was not prose. Some tests, however, approach the body not with code that has no prose at all, but with text written in a scenario language — describing steps with ordinary sentences and expecting those sentences to have a counterpart. This carries the question of which class path the test sees toward yet another direction: what is now being sought is no longer a mismatch between two code bases, but a mismatch between a prose text and that text’s executable counterpart. The next lesson treats such a text as a declaration, and its question is: does every step in the text have an executable counterpart, and when some steps do not have that counterpart, are they silently skipped.
To keep your progress and take notes, Log in
My notes
Log in to take notes.