|
| 1 | +# Testing Strategy — S3Mock |
| 2 | + |
| 3 | +## Test Types |
| 4 | + |
| 5 | +| Type | Location | Suffix | Purpose | |
| 6 | +|------|----------|--------|---------| |
| 7 | +| Unit tests | `server/src/test/kotlin/` | `*Test.kt` | Service, store, and controller logic in isolation | |
| 8 | +| Integration tests | `integration-tests/src/test/kotlin/.../its/` | `*IT.kt` | Real AWS SDK v2 against a live Docker container | |
| 9 | + |
| 10 | +## Unit Tests |
| 11 | + |
| 12 | +Use `@SpringBootTest` with `@MockitoBean` for mocking. Extend the appropriate base class: |
| 13 | + |
| 14 | +| Base Class | Use For | |
| 15 | +|---|---| |
| 16 | +| `ServiceTestBase` | Service-layer tests | |
| 17 | +| `StoreTestBase` | Store-layer tests | |
| 18 | +| `BaseControllerTest` | Controller slice tests (`@WebMvcTest`) | |
| 19 | + |
| 20 | +Name the class under test **`iut`** (implementation under test); inject with `@Autowired`: |
| 21 | + |
| 22 | +```kotlin |
| 23 | +@SpringBootTest(classes = [ServiceConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) |
| 24 | +@MockitoBean(types = [BucketService::class, MultipartService::class, MultipartStore::class]) |
| 25 | +internal class ObjectServiceTest : ServiceTestBase() { |
| 26 | + @Autowired |
| 27 | + private lateinit var iut: ObjectService |
| 28 | + |
| 29 | + @Test |
| 30 | + fun `should get object`() { |
| 31 | + whenever(bucketStore.getBucketMetadata("bucket")).thenReturn(bucket) |
| 32 | + whenever(objectStore.getObject(bucket, "key")).thenReturn(s3Object) |
| 33 | + assertThat(iut.getObject("bucket", "key")).isEqualTo(s3Object) |
| 34 | + } |
| 35 | +} |
| 36 | +``` |
| 37 | + |
| 38 | +## Integration Tests |
| 39 | + |
| 40 | +Extend `S3TestBase` for a pre-configured `s3Client` (AWS SDK v2). Accept `TestInfo` as a method parameter and use `givenBucket(testInfo)` for unique bucket names: |
| 41 | + |
| 42 | +```kotlin |
| 43 | +internal class MyFeatureIT : S3TestBase() { |
| 44 | + @Test |
| 45 | + fun `should perform operation`(testInfo: TestInfo) { |
| 46 | + // Arrange |
| 47 | + val bucketName = givenBucket(testInfo) |
| 48 | + |
| 49 | + // Act |
| 50 | + s3Client.putObject( |
| 51 | + PutObjectRequest.builder().bucket(bucketName).key("key").build(), |
| 52 | + RequestBody.fromString("content") |
| 53 | + ) |
| 54 | + |
| 55 | + // Assert |
| 56 | + val response = s3Client.getObject( |
| 57 | + GetObjectRequest.builder().bucket(bucketName).key("key").build() |
| 58 | + ) |
| 59 | + assertThat(response.readAllBytes().decodeToString()).isEqualTo("content") |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +Access `serviceEndpoint`, `serviceEndpointHttp`, and `serviceEndpointHttps` from `S3TestBase` when needed. |
| 65 | + |
| 66 | +## Conventions |
| 67 | + |
| 68 | +- **Naming**: Backtick names with descriptive sentences — `` fun `should create bucket successfully`() `` |
| 69 | +- **Visibility**: Mark test classes `internal` |
| 70 | +- **Pattern**: Arrange-Act-Assert |
| 71 | +- **Assertions**: AssertJ (`assertThat(...)`) — use specific matchers, not just `isNotNull()` |
| 72 | +- **Error cases**: Use AssertJ, not JUnit `assertThrows`: |
| 73 | + ```kotlin |
| 74 | + assertThatThrownBy { s3Client.deleteBucket { it.bucket(bucketName) } } |
| 75 | + .isInstanceOf(AwsServiceException::class.java) |
| 76 | + .hasMessageContaining("Status Code: 409") |
| 77 | + ``` |
| 78 | +- **Independence**: Each test creates its own resources — no shared state, UUID-based bucket names |
| 79 | +- **Legacy names**: Refactor `testSomething` camelCase names to backtick style when touching existing tests |
| 80 | + |
| 81 | +## Running Tests |
| 82 | + |
| 83 | +```bash |
| 84 | +./mvnw test -pl server # Unit tests only |
| 85 | +./mvnw verify -pl integration-tests # All integration tests |
| 86 | +./mvnw verify -pl integration-tests -Dit.test=BucketIT # Specific class |
| 87 | +./mvnw verify -pl integration-tests -Dit.test=BucketIT#shouldCreateBucket # Specific method |
| 88 | +./mvnw test -pl server -DskipDocker # Skip Docker |
| 89 | +``` |
| 90 | + |
| 91 | +> Integration tests require Docker to be running. |
| 92 | +
|
| 93 | +## Troubleshooting |
| 94 | + |
| 95 | +- **Docker not running**: Integration tests will fail — start Docker first |
| 96 | +- **Port conflict**: Check `lsof -i :9090` |
| 97 | +- **Flaky test**: Look for shared state or ordering dependencies |
| 98 | +- **Compilation error**: Run `./mvnw clean install -DskipDocker -DskipTests` first |
| 99 | + |
| 100 | +## Checklist |
| 101 | + |
| 102 | +- [ ] Tests pass locally |
| 103 | +- [ ] Both success and failure cases covered |
| 104 | +- [ ] Tests are independent (no shared state, UUID bucket names) |
| 105 | +- [ ] Assertions are specific |
| 106 | +- [ ] Run `./mvnw ktlint:format` |
0 commit comments