Skip to content

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