tenantlayer.io

Testing

The assertion

@Test
@WithTenant("acme")
void oneTenantCannotSeeAnother() {
    assertThat(orders.findAll()).isNotEmpty();
    assertTenantCannotSee("globex");
}

assertTenantCannotSee is deliberately two-sided. Proving "tenant A sees no rows belonging to B" is worthless alone — an empty table passes it. So it first checks, on a privileged connection that bypasses the policy, that B's rows genuinely exist, and fails with a message telling you to seed them if they do not. It also refuses to run with no tenant bound, and refuses when the acting tenant is the tenant you asked about.

The fixture

static final TenantPostgres POSTGRES = TenantPostgres.start()
        .withTenantTable("orders", "item varchar(255) not null")
        .withRegistry("acme", "globex");
 
@BeforeEach
void bind() {
    POSTGRES.seedRow("orders", "acme",   Map.of("item", "laptop"));
    POSTGRES.seedRow("orders", "globex", Map.of("item", "monitor"));
    IsolationAssertions.bind(POSTGRES.applicationDataSource(), POSTGRES.privilegedDataSource());
    IsolationAssertions.bindTable("orders", "tenant_id");
}

withTenantTable creates the table with the index, FORCE ROW LEVEL SECURITY and a correctly guarded policy. withUnprotectedTable creates one without a policy, for testing the discriminator strategy on its own.

Two DataSources, and they are not interchangeable

  • applicationDataSource() — a least-privileged role, neither superuser nor owner. This is what the code under test uses, and it is subject to the policy.
  • privilegedDataSource() — bypasses the policy. For seeding and for proving another tenant's rows exist. Never the subject of an assertion.

Standing up a Postgres container is one line. Standing up one that can prove isolation is not, and the trap is the connection you test through: Testcontainers hands you a superuser, superusers bypass RLS outright, and a suite written against that connection passes whether or not the policy works — including after someone deletes it.

The pool defaults to one connection, so a tenant left behind on a recycled connection is observed deterministically rather than occasionally.

A complete test class

TenantPostgres is a standalone fixture — it does not need a Spring context, which keeps isolation tests fast and keeps them testing the database rather than your wiring:

class ReportIsolationTest {
 
    private static TenantPostgres postgres;
    private static DataSource application;
 
    @BeforeAll
    static void startDatabase() {
        postgres = TenantPostgres.start()
                .withTenantTable("reports", "title varchar(255) not null")
                .withRegistry("acme", "globex");
        // One connection, so a tenant left behind on a recycled connection is
        // observed every time rather than occasionally.
        application = postgres.applicationDataSource(1);
    }
 
    @BeforeEach
    void seed() {
        postgres.execute("truncate table reports restart identity");
        postgres.seedRow("reports", "acme",   Map.of("title", "acme q3"));
        postgres.seedRow("reports", "globex", Map.of("title", "globex q3"));
 
        IsolationAssertions.bind(application, postgres.privilegedDataSource());
        IsolationAssertions.bindTable("reports", "tenant_id");
    }
 
    @AfterEach
    void clearContext() {
        TenantContext.clear();
    }
 
    @Test
    @WithTenant("acme")
    void acmeSeesOnlyItsOwnReports() {
        assertTenantCannotSee("globex");
    }
}

withTenantTable creates the table, the index, FORCE ROW LEVEL SECURITY and a correctly guarded policy — so the fixture cannot pass for the wrong reason, which is the usual way an isolation suite goes green while proving nothing.

Testing over HTTP

The test above proves the database enforces isolation. This one proves your endpoints do not undo it — which is a different claim, and the one your users actually depend on:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiTest {
 
    @Autowired
    private TestRestTemplate http;
 
    @Test
    void oneTenantCannotFetchAnothersOrderById() {
        long globexOrder = place("globex", "monitor");
 
        // acme knows the id and asks for it directly.
        ResponseEntity<String> response = get("/orders/" + globexOrder, "acme");
 
        assertThat(response.getStatusCode())
                .as("acme fetched globex's order by id")
                .isEqualTo(HttpStatus.NOT_FOUND);
    }
 
    @Test
    void aRequestWithNoTenantIsRejectedRatherThanServedEmpty() {
        ResponseEntity<String> response = http.exchange(
                "/orders", HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
    }
 
    private ResponseEntity<String> get(String path, String tenant) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("X-Tenant-ID", tenant);
        return http.exchange(path, HttpMethod.GET, new HttpEntity<>(headers), String.class);
    }
}

The second test is the one people leave out. Without it, turning strict mode off looks like a passing suite.

Testing that the tenant survives a thread hop

Propagation failures are silent — the work runs with no tenant and returns nothing — so they need an explicit test:

@Test
@WithTenant("acme")
void theTenantSurvivesAnAsyncCall() throws Exception {
    CompletableFuture<String> seen = new CompletableFuture<>();
 
    asyncProbe.record(seen);          // an @Async method that reports its tenant
 
    assertThat(seen.get(5, SECONDS))
            .as("the tenant was lost crossing to the pool thread")
            .isEqualTo("acme");
}

Make it fail once before you trust it: have the probe run on the calling thread, and check the assertion still passes. If it does, the test never crossed a thread boundary and proves nothing.

Break it and watch it fail

Green tests are not evidence. After a test passes, break the implementation and confirm the test goes red. Every isolation claim in this library was checked that way, and it caught something real every time — including a BUILD SUCCESS with zero tests executed, an @Async test that would have passed if the work never left the calling thread, and an assertTenantCannotSee that passed with the library switched off entirely.

The cheapest version of this for your own code:

mvn test -Dspring.autoconfigure.exclude=io.tenantlayer.autoconfigure.TenantLayerAutoConfiguration

If your isolation tests still pass with TenantLayer turned off, they are not testing isolation.