Testing Strategy
Status: Final Version: 1.0
Purpose
Define test types, coverage targets, fixture strategy, and CI/CD integration for the ESG platform.
Test Pyramid
| Layer | Ratio | Tools | Coverage Target |
|---|---|---|---|
| Unit Tests | 70% | JUnit 5 / Kotest | 80% code coverage |
| Feature Tests | 20% | RestAssured | Critical workflows |
| Integration Tests | 8% | Quarkus Test + DevServices | API contracts, async jobs |
| E2E Tests | 2% | Selenium WebDriver | Critical user journeys |
Unit Tests
Scope: Model methods, service classes, validation logic
Example: Validation Rule Test
@QuarkusTest
class MetricValidationServiceTest {
@Inject
private lateinit var validationService: ValidationService
@Test
fun `numeric validation passes for valid number`() {
val rule = ValidationRule(type = "schema", rule = "numeric")
val submission = MetricSubmission(
rawData = mapOf("value" to 123.45),
// ... other required fields
)
val result = validationService.validateSchema(submission, rule)
assertTrue(result)
}
@Test
fun `numeric validation fails for string`() {
val rule = ValidationRule(type = "schema", rule = "numeric")
val submission = MetricSubmission(
rawData = mapOf("value" to "abc"),
// ... other required fields
)
val result = validationService.validateSchema(submission, rule)
assertFalse(result)
}
}
Feature Tests
Scope: API endpoints, RBAC policies, workflows
Example: Submission Approval Test
import io.quarkus.test.junit.QuarkusTest
import io.quarkus.test.security.TestSecurity
import io.restassured.RestAssured.given
import io.restassured.http.ContentType
import jakarta.inject.Inject
import org.junit.jupiter.api.Test
import org.hamcrest.Matchers.equalTo
@QuarkusTest
class SubmissionApprovalTest {
@Inject
private lateinit var submissionRepository: MetricSubmissionRepository
@Inject
private lateinit var userRepository: UserRepository
@Test
@TestSecurity(user = "approver", roles = ["APPROVER"])
fun `approver can approve submission`() {
val approver = userRepository.persist(User(/* ... */, roles = setOf("APPROVER")))
val submission = submissionRepository.persist(MetricSubmission(/* ... */, state = "VALIDATED"))
given()
.contentType(ContentType.JSON)
.body("""{"comment": "Data verified"}""")
.`when`()
.post("/api/v1/admin/submissions/${submission.id}/approve")
.then()
.statusCode(200)
.body("state", equalTo("APPROVED"))
val updated = submissionRepository.findByIdOptional(submission.id!!).orElseThrow()
assertEquals("APPROVED", updated.state)
assertEquals(approver.id, updated.approvedByUserId)
}
@Test
@TestSecurity(user = "collector", roles = ["COLLECTOR"])
fun `collector cannot approve submission`() {
val collector = userRepository.persist(User(/* ... */, roles = setOf("COLLECTOR")))
val submission = submissionRepository.persist(MetricSubmission(/* ... */, state = "VALIDATED"))
given()
.contentType(ContentType.JSON)
.body("""{"comment": "Attempt"}""")
.`when`()
.post("/api/v1/admin/submissions/${submission.id}/approve")
.then()
.statusCode(403)
}
}
Audit Log Invariant Tests
Requirement: Every state transition MUST create audit log entry.
@Test
@TestSecurity(user = "approver", roles = ["APPROVER"])
fun `submission approval creates audit log`() {
val approver = userRepository.persist(User(/* ... */, roles = setOf("APPROVER")))
val submission = submissionRepository.persist(MetricSubmission(/* ... */, state = "VALIDATED"))
given()
.contentType(ContentType.JSON)
.body("""{"comment": "Data verified"}""")
.`when`()
.post("/api/v1/admin/submissions/${submission.id}/approve")
.then()
.statusCode(200)
val auditLog = auditLogRepository.findByEntityTypeAndEntityId("MetricSubmission", submission.id!!)
assertNotNull(auditLog)
assertEquals("submission.approved", auditLog!!.action)
assertEquals(approver.id, auditLog.actorUserId)
}
API Contract Tests
Scope: Validate request/response schemas match documentation.
Example: Submission Create Contract
import org.hamcrest.Matchers.notNullValue
@Test
@TestSecurity(user = "collector", roles = ["COLLECTOR"])
fun `submission create response matches schema`() {
val collector = userRepository.persist(User(/* ... */, roles = setOf("COLLECTOR")))
val requestBody = """
{
"submission_uuid": "${UUID.randomUUID()}",
"reporting_period_id": 1,
"site_id": 1,
"metric_id": "GRI_302_1_ELECTRICITY",
"value": 1250.50,
"unit": "MWh"
}
""".trimIndent()
given()
.contentType(ContentType.JSON)
.body(requestBody)
.`when`()
.post("/api/v1/collector/submissions")
.then()
.statusCode(201)
.body("id", notNullValue())
.body("submission_uuid", notNullValue())
.body("state", notNullValue())
.body("submitted_at", notNullValue())
.body("validation_status", notNullValue())
.body("next_steps", notNullValue())
}
Data Fixtures & Seeders
Test Fixtures
// src/test/kotlin/fixtures/MetricSubmissionFixtures.kt
object MetricSubmissionFixtures {
fun createMetricSubmission(
tenantId: Long = 1L,
submissionUuid: UUID = UUID.randomUUID(),
reportingPeriodId: Long = 1L,
siteId: Long = 1L,
metricDefinitionId: String = "GRI_302_1_ELECTRICITY",
rawData: Map<String, Any> = mapOf("value" to Random.nextDouble(0.0, 10000.0)),
state: String = "RECEIVED",
submittedByUserId: Long = 1L,
submittedAt: Instant = Instant.now()
): MetricSubmission {
return MetricSubmission(
tenantId = tenantId,
submissionUuid = submissionUuid,
reportingPeriodId = reportingPeriodId,
siteId = siteId,
metricDefinitionId = metricDefinitionId,
rawData = rawData,
state = state,
submittedByUserId = submittedByUserId,
submittedAt = submittedAt
)
}
fun validated(submission: MetricSubmission): MetricSubmission {
return submission.copy(state = "VALIDATED")
}
fun approved(submission: MetricSubmission, approverUserId: Long = 2L): MetricSubmission {
return submission.copy(
state = "APPROVED",
approvedByUserId = approverUserId,
approvedAt = Instant.now()
)
}
}
Demo Data Initializer
// src/test/kotlin/data/DemoDataInitializer.kt
import io.quarkus.runtime.StartupEvent
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import java.time.LocalDate
@ApplicationScoped
class DemoDataInitializer @Inject constructor(
private val tenantRepository: TenantRepository,
private val organisationRepository: OrganisationRepository,
private val reportingPeriodRepository: ReportingPeriodRepository,
private val submissionRepository: MetricSubmissionRepository
) {
@Transactional
fun init(@Observes event: StartupEvent) {
// Only run if demo profile is active
if (System.getProperty("quarkus.profile") != "demo") {
return
}
val tenant = tenantRepository.persist(
Tenant(name = "Demo Corp", slug = "demo-corp")
)
val org = organisationRepository.persist(
Organisation(
tenantId = tenant.id!!,
name = "Demo Corporation",
code = "DEMO"
)
)
val period = reportingPeriodRepository.persist(
ReportingPeriod(
tenantId = tenant.id!!,
organisationId = org.id!!,
name = "FY2025",
startDate = LocalDate.of(2025, 1, 1),
endDate = LocalDate.of(2025, 12, 31),
fiscalYear = 2025,
state = "OPEN"
)
)
// Create 100 sample submissions
repeat(100) {
submissionRepository.persist(
MetricSubmissionFixtures.createMetricSubmission(
tenantId = tenant.id!!,
reportingPeriodId = period.id!!
)
)
}
}
}
CI/CD Pipeline
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
# Note: Quarkus DevServices automatically starts PostgreSQL container
# No need for services section unless you want specific configuration
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run Tests with Coverage
run: ./gradlew test jacocoTestReport
# DevServices automatically handles test database
# Or override if needed:
# env:
# QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://localhost:5432/esg_test
# QUARKUS_DATASOURCE_USERNAME: postgres
# QUARKUS_DATASOURCE_PASSWORD: password
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./build/reports/jacoco/test/jacocoTestReport.xml
Quarkus DevServices
Quarkus automatically starts test containers when running tests:
# application.properties (test profile)
# DevServices is enabled by default - no configuration needed!
# Quarkus automatically detects PostgreSQL dependency and starts container
# Optional: Customize DevServices
%test.quarkus.datasource.devservices.enabled=true
%test.quarkus.datasource.devservices.image-name=postgres:15
%test.quarkus.datasource.devservices.port=5432
Benefits:
- No manual Testcontainers configuration
- Automatic container lifecycle management
- Shared database across test classes (faster)
- Clean database state per test class with @QuarkusTestResource
Acceptance Criteria
- Unit test coverage ≥ 80% (measured by JaCoCo or Kover)
- All API endpoints have feature tests (request/response validation)
- Audit log invariant tests pass (every mutation creates log)
- Test fixtures exist for all core entities (submissions, evidence, users)
- Demo data initializer creates realistic test data
- CI/CD pipeline runs tests on every PR
- Code coverage report uploaded to Codecov
Cross-References
Change Log
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-03 | Senior Product Architect | Initial testing strategy specification |