Skip to content

Commit f3745e8

Browse files
Merge pull request #29 from OpenElementsLabs/feat/103-tests-postgres-testcontainers
feat(spec-103): backend tests on PostgreSQL via Testcontainers (drop H2)
2 parents 2a9b751 + 3f7ee6e commit f3745e8

17 files changed

Lines changed: 171 additions & 154 deletions

.claude/conventions/backend.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ When building libraries that target backend applications, provide support for Sp
3232
- **IMPORTANT**: Use **JPA** (Jakarta Persistence API) for data access. Do not use implementation-specific APIs (e.g., Hibernate session or criteria API directly) — always program against the JPA interfaces.
3333
- Use **[Flyway](https://flywaydb.org/)** for database schema management and migrations in all projects with a database.
3434
- **PostgreSQL** is the preferred database for test environments and production.
35-
- **H2** (in-memory) is the preferred database for fast, automated unit/integration tests. In the future, we plan to replace H2 with [Testcontainers](https://www.testcontainers.org/)-based PostgreSQL to test against the same database in all environments.
35+
- Automated tests run against the same PostgreSQL version as production via [Testcontainers](https://testcontainers.com/). Docker must be available on the developer machine and on CI runners (Linux `ubuntu-latest` ships with it). H2 is no longer used.
3636
- **IMPORTANT**: Database connection URLs, credentials, and other settings must be configurable via environment variables (see [fullstack-architecture.md](fullstack-architecture.md#configuration)).
3737

3838
## Data Privacy and GDPR
@@ -60,24 +60,24 @@ Every backend layer must have its own tests. Tests at a higher layer do not repl
6060

6161
**Repository tests (`@DataJpaTest`)**
6262
- Every repository interface with custom query methods must have a test class.
63-
- Use `@DataJpaTest` — it auto-configures an embedded H2 database and rolls back after each test. No `@ActiveProfiles` annotation needed.
63+
- Use `@DataJpaTest` and the project's `AbstractDbTest` so the test runs against a real PostgreSQL container with Flyway-managed schema.
6464
- Use `TestEntityManager` (`persistAndFlush` + `clear()`) to force real database roundtrips instead of hitting the first-level cache.
6565
- Test custom query methods, pagination, and database constraints (NOT NULL, unique).
6666

6767
**Service tests (`@SpringBootTest`)**
6868
- Every service class must have a test class that covers all public methods.
69-
- Use `@SpringBootTest` with `@ActiveProfiles("test")` and a real H2 database — do not mock repositories.
70-
- Clean up test data in `@BeforeEach` by deleting via repositories in foreign-key order. Do not use `@Transactional` on service tests — this hides transaction boundary bugs in service methods that are themselves `@Transactional`.
69+
- Extend `AbstractDbTest` — it provides `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ActiveProfiles("test")`, a shared Postgres container, and per-test `TRUNCATE` cleanup. Do not redeclare those annotations.
70+
- Do not use `@Transactional` on service tests — this hides transaction boundary bugs in service methods that are themselves `@Transactional`.
7171
- Test happy paths, validation errors, cross-entity business logic, and edge cases.
7272

7373
**Controller tests (`@SpringBootTest` + `MockMvc`)**
7474
- Every controller must have a test class that verifies HTTP status codes, request/response serialization, and validation.
75-
- Use `@SpringBootTest` with `@AutoConfigureMockMvc` and `@ActiveProfiles("test")`.
75+
- Extend `AbstractDbTest` (which already configures MockMvc).
7676

7777
### General test rules
7878

7979
- Do not mock repositories or other internal dependencies when the real implementation is fast and available. Mocks add complexity without proportional value in small codebases and miss integration bugs.
80-
- Use H2 for all automated tests. In the future, Testcontainers with PostgreSQL will replace H2 (see Data Access section).
80+
- All Spring-based tests run against a real PostgreSQL container via `AbstractDbTest` (see Data Access section). Pure unit tests (no Spring context) do not need to extend it.
8181
- Test classes live in the same package structure as the code they test.
8282

8383
## Observability

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,19 @@ docker compose down -v # removes database data
146146
cd backend && ./mvnw clean verify
147147
```
148148

149+
> Backend tests run against a real PostgreSQL 17 container started by
150+
> [Testcontainers](https://testcontainers.com/). **Docker (Docker Desktop or
151+
> any Docker-compatible daemon) must be running** before `./mvnw test` /
152+
> `./mvnw verify` is invoked. For faster local iteration, enable container
153+
> reuse by adding the following line to `~/.testcontainers.properties`:
154+
>
155+
> ```properties
156+
> testcontainers.reuse.enable=true
157+
> ```
158+
>
159+
> With reuse on, the same Postgres container survives between `mvn test`
160+
> runs and the per-suite startup cost drops from ~3–5 s to ~200 ms.
161+
149162
**Frontend:**
150163
151164
```bash

backend/pom.xml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,20 @@
131131
</dependency>
132132

133133
<dependency>
134-
<groupId>com.h2database</groupId>
135-
<artifactId>h2</artifactId>
134+
<groupId>org.springframework.boot</groupId>
135+
<artifactId>spring-boot-testcontainers</artifactId>
136+
<scope>test</scope>
137+
</dependency>
138+
139+
<dependency>
140+
<groupId>org.testcontainers</groupId>
141+
<artifactId>junit-jupiter</artifactId>
142+
<scope>test</scope>
143+
</dependency>
144+
145+
<dependency>
146+
<groupId>org.testcontainers</groupId>
147+
<artifactId>postgresql</artifactId>
136148
<scope>test</scope>
137149
</dependency>
138150
</dependencies>

backend/src/main/java/com/openelements/crm/auditlog/AuditLogController.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ public Page<AuditLogDto> listAuditLogs(
6868
+ "Used to populate the entity-type filter dropdown. Requires the IT-ADMIN role."
6969
)
7070
public List<String> listEntityTypes() {
71-
return auditLogService.findAllEntityTypes();
71+
// Sort explicitly: the upstream findAllEntityTypes() returns rows in
72+
// the database's natural order (insertion order on H2, undefined on
73+
// Postgres) — the endpoint's contract is "sorted alphabetically".
74+
return auditLogService.findAllEntityTypes().stream().sorted().toList();
7275
}
7376
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.openelements.crm;
2+
3+
import com.openelements.spring.base.services.user.SystemUser;
4+
import com.openelements.spring.base.services.user.UserRepository;
5+
import org.junit.jupiter.api.AfterEach;
6+
import org.springframework.beans.factory.annotation.Autowired;
7+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
8+
import org.springframework.boot.test.context.SpringBootTest;
9+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
10+
import org.springframework.jdbc.core.JdbcTemplate;
11+
import org.springframework.test.context.ActiveProfiles;
12+
import org.testcontainers.containers.PostgreSQLContainer;
13+
14+
/**
15+
* Shared test base for Spring Boot tests that touch the database. Starts a
16+
* single {@code postgres:17-alpine} container once per JVM, registers it as
17+
* the application {@link javax.sql.DataSource} via {@link ServiceConnection},
18+
* and truncates all application tables between tests for per-method isolation.
19+
*
20+
* <p>The container is reused across runs when {@code testcontainers.reuse.enable=true}
21+
* is set in {@code ~/.testcontainers.properties} (local dev). CI runs ignore
22+
* the reuse hint, so each build gets a fresh container.
23+
*
24+
* <p>Adding a new table in a Flyway migration requires extending {@link #TABLES_TO_TRUNCATE}.
25+
* This is intentional — the explicit list keeps the truncate fast and predictable.
26+
*
27+
* <p>The {@code @Testcontainers} JUnit extension is intentionally not used —
28+
* it discovers {@code @Container} fields and does nothing for a manually
29+
* started container. The {@code static { POSTGRES.start(); }} block is what
30+
* actually brings the container up before any subclass context loads.
31+
*/
32+
@SpringBootTest
33+
@AutoConfigureMockMvc
34+
@ActiveProfiles("test")
35+
public abstract class AbstractDbTest {
36+
37+
/**
38+
* Tables truncated between tests. Order is irrelevant because
39+
* {@code CASCADE} drops dependent rows.
40+
*/
41+
private static final String TABLES_TO_TRUNCATE = String.join(", ",
42+
"api_keys",
43+
"audit_log",
44+
"comments",
45+
"company_comments",
46+
"company_tags",
47+
"contact_comments",
48+
"contact_social_links",
49+
"contact_tags",
50+
"contacts",
51+
"companies",
52+
"settings",
53+
"task_comments",
54+
"task_tags",
55+
"tasks",
56+
"tags",
57+
"users",
58+
"webhooks"
59+
);
60+
61+
@ServiceConnection
62+
static final PostgreSQLContainer<?> POSTGRES =
63+
new PostgreSQLContainer<>("postgres:17-alpine")
64+
.withReuse(true);
65+
66+
static {
67+
POSTGRES.start();
68+
}
69+
70+
@Autowired
71+
private JdbcTemplate jdbcTemplate;
72+
73+
@Autowired
74+
private UserRepository userRepository;
75+
76+
@AfterEach
77+
void truncateAllTables() {
78+
// RESTART IDENTITY keeps sequence-backed columns deterministic; CASCADE
79+
// drops dependent rows so order of tables is irrelevant.
80+
jdbcTemplate.execute("TRUNCATE TABLE " + TABLES_TO_TRUNCATE + " RESTART IDENTITY CASCADE");
81+
}
82+
83+
/**
84+
* (Re-)inserts the {@link SystemUser} row needed by audit-log / comment
85+
* tests that reference {@code SystemUser.ID} as a foreign key. Idempotent:
86+
* subclasses call this from their own {@code @BeforeEach} when needed.
87+
* The {@code @AfterEach} truncate wipes {@code users}, so the seed must
88+
* be re-applied per test method.
89+
*/
90+
protected void seedSystemUser() {
91+
if (userRepository.findBySub(SystemUser.SUB).isEmpty()) {
92+
jdbcTemplate.update(
93+
"INSERT INTO users (id, sub, name, created_at, updated_at) "
94+
+ "VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
95+
SystemUser.ID, SystemUser.SUB, SystemUser.NAME);
96+
}
97+
}
98+
}

backend/src/test/java/com/openelements/crm/auditlog/AuditLogControllerTest.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
package com.openelements.crm.auditlog;
22

3+
import com.openelements.crm.AbstractDbTest;
34
import com.openelements.spring.base.services.audit.AuditAction;
45
import com.openelements.spring.base.services.audit.AuditLogDataService;
56
import com.openelements.spring.base.services.user.UserEntity;
67
import com.openelements.spring.base.services.user.UserRepository;
78
import org.junit.jupiter.api.BeforeEach;
89
import org.junit.jupiter.api.Test;
910
import org.springframework.beans.factory.annotation.Autowired;
10-
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
11-
import org.springframework.boot.test.context.SpringBootTest;
1211
import org.springframework.jdbc.core.JdbcTemplate;
1312
import org.springframework.security.core.GrantedAuthority;
1413
import org.springframework.security.core.authority.SimpleGrantedAuthority;
1514
import org.springframework.security.oauth2.jwt.Jwt;
16-
import org.springframework.test.context.ActiveProfiles;
1715
import org.springframework.test.web.servlet.MockMvc;
1816
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
1917

@@ -36,10 +34,7 @@
3634
* focuses on the API contract — pagination, filter combinations, and the
3735
* entity-types listing.
3836
*/
39-
@SpringBootTest
40-
@AutoConfigureMockMvc
41-
@ActiveProfiles("test")
42-
class AuditLogControllerTest {
37+
class AuditLogControllerTest extends AbstractDbTest {
4338

4439
@Autowired
4540
private MockMvc mockMvc;

backend/src/test/java/com/openelements/crm/comment/CommentEndpointsIntegrationTest.java

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.fasterxml.jackson.databind.JsonNode;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.openelements.crm.AbstractDbTest;
56
import com.openelements.crm.company.CompanyEntity;
67
import com.openelements.crm.company.CompanyRepository;
78
import com.openelements.crm.contact.ContactEntity;
@@ -11,17 +12,13 @@
1112
import com.openelements.spring.base.services.comment.CommentService;
1213
import com.openelements.spring.base.services.user.SystemUser;
1314
import com.openelements.spring.base.services.user.UserRepository;
14-
import org.junit.jupiter.api.AfterEach;
1515
import org.junit.jupiter.api.BeforeEach;
1616
import org.junit.jupiter.api.Test;
1717
import org.springframework.beans.factory.annotation.Autowired;
18-
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
19-
import org.springframework.boot.test.context.SpringBootTest;
2018
import org.springframework.http.MediaType;
2119
import org.springframework.security.core.GrantedAuthority;
2220
import org.springframework.security.core.authority.SimpleGrantedAuthority;
2321
import org.springframework.security.oauth2.jwt.Jwt;
24-
import org.springframework.test.context.ActiveProfiles;
2522
import org.springframework.test.web.servlet.MockMvc;
2623
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
2724

@@ -43,10 +40,7 @@
4340
* spec 094. Covers happy paths, validation, mismatched ownership, listing, and
4441
* the cascade behaviour on owner deletion.
4542
*/
46-
@SpringBootTest
47-
@AutoConfigureMockMvc
48-
@ActiveProfiles("test")
49-
class CommentEndpointsIntegrationTest {
43+
class CommentEndpointsIntegrationTest extends AbstractDbTest {
5044

5145
@Autowired
5246
private MockMvc mockMvc;
@@ -76,22 +70,8 @@ class CommentEndpointsIntegrationTest {
7670
private ObjectMapper objectMapper;
7771

7872
@BeforeEach
79-
void seedSystemUser() {
80-
if (userRepository.findBySub(SystemUser.SUB).isEmpty()) {
81-
jdbcTemplate.update(
82-
"INSERT INTO users (id, sub, name, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
83-
SystemUser.ID, SystemUser.SUB, SystemUser.NAME);
84-
}
85-
}
86-
87-
@AfterEach
88-
void clean() {
89-
// Delete owners first to clear join-table rows, then the orphaned comments.
90-
jdbcTemplate.update("DELETE FROM company_comments");
91-
jdbcTemplate.update("DELETE FROM contact_comments");
92-
contactRepository.deleteAll();
93-
companyRepository.deleteAll();
94-
commentRepository.deleteAll();
73+
void seed() {
74+
seedSystemUser();
9575
}
9676

9777
private static MockHttpServletRequestBuilder asUser(MockHttpServletRequestBuilder builder, List<String> roles) {

backend/src/test/java/com/openelements/crm/contact/ContactPhotoHeicWebpIntegrationTest.java

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,20 @@
88
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
99
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1010

11+
import com.openelements.crm.AbstractDbTest;
1112
import com.openelements.spring.base.services.user.SystemUser;
1213
import com.openelements.spring.base.services.user.UserRepository;
1314
import java.util.ArrayList;
1415
import java.util.Collection;
1516
import java.util.List;
16-
import org.junit.jupiter.api.AfterEach;
1717
import org.junit.jupiter.api.BeforeEach;
1818
import org.junit.jupiter.api.Disabled;
1919
import org.junit.jupiter.api.Test;
2020
import org.springframework.beans.factory.annotation.Autowired;
21-
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
22-
import org.springframework.boot.test.context.SpringBootTest;
2321
import org.springframework.mock.web.MockMultipartFile;
2422
import org.springframework.security.core.GrantedAuthority;
2523
import org.springframework.security.core.authority.SimpleGrantedAuthority;
2624
import org.springframework.security.oauth2.jwt.Jwt;
27-
import org.springframework.test.context.ActiveProfiles;
2825
import org.springframework.test.web.servlet.MockMvc;
2926
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
3027
import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder;
@@ -41,10 +38,7 @@
4138
* has no probe sample on classpath, so {@code HeicSupportCheck.isHeicAvailable()}
4239
* naturally returns false) and the malformed-WebP rejection path.
4340
*/
44-
@SpringBootTest
45-
@AutoConfigureMockMvc
46-
@ActiveProfiles("test")
47-
class ContactPhotoHeicWebpIntegrationTest {
41+
class ContactPhotoHeicWebpIntegrationTest extends AbstractDbTest {
4842

4943
private static final String FIXTURE_TODO =
5044
"Awaiting test fixtures — see TODO.md: HEIC- und WebP-Testfixtures bereitstellen";
@@ -65,17 +59,8 @@ class ContactPhotoHeicWebpIntegrationTest {
6559
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
6660

6761
@BeforeEach
68-
void seedSystemUser() {
69-
if (userRepository.findBySub(SystemUser.SUB).isEmpty()) {
70-
jdbcTemplate.update(
71-
"INSERT INTO users (id, sub, name, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
72-
SystemUser.ID, SystemUser.SUB, SystemUser.NAME);
73-
}
74-
}
75-
76-
@AfterEach
77-
void clean() {
78-
contactRepository.deleteAll();
62+
void seed() {
63+
seedSystemUser();
7964
}
8065

8166
private static <T extends MockHttpServletRequestBuilder> T asUser(final T builder) {

0 commit comments

Comments
 (0)