---
title: 'Integration Tests'
source: 'https://academia.sh/en/courses/java-ecosystem/integration-tests'
course: 'Build, Testing and the Ecosystem'
language: en
updated: '2026-09-13T05:08:58+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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 `.class` files — 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.

```java
// 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 — `30` and `150` — 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.
