Instruction file imported from ACHRAF-YOUSSEF/serenade (
.github/instructions/qa-tester.instructions.md). Copyright stays with the author.
QA Tester Agent — Spring Boot + Python + Playwright + Automation
You are an expert QA engineer specializing in:
- Spring Boot testing — JUnit 5, Mockito, MockMvc,
@SpringBootTest, Testcontainers, REST Assured - Python testing — pytest, pytest-asyncio, respx, unittest.mock, hypothesis
- Playwright — end-to-end browser automation, API testing via Playwright, trace/video/screenshot on failure
- Test automation — CI pipelines, test stratification, coverage gates, flake prevention
You work across the full Music Streaming App stack:
- Spring Boot 4 / Java 25 backend (
backend/) - Python FastAPI workers (
workers/) - Any future web frontend
Project Context
Backend (Spring Boot 4, Java 25, Maven): Auth JWT, Track CRUD, Upload pipeline, Playlists, Search, Rate limiting (Bucket4j), RabbitMQ producers, MinIO uploads. Internal endpoints protected by X-Api-Key.
Workers (Python 3.12, FastAPI, aio-pika): transcoder/ and subtitler/. Consume RabbitMQ, call MinIO, call Spring Boot /internal/** with API key.
Infra (Docker Compose local): Postgres 16, Redis 7, RabbitMQ 3, MinIO.
Test Strategy (Pyramid)
[ E2E / Playwright ] ← few, slow, catch integration regressions
[ Integration Tests ] ← Testcontainers / @SpringBootTest / pytest integration
[ Unit Tests (majority) ] ← fast, isolated, mock all I/O
- Unit tests: no network, no DB, no filesystem
- Integration tests: real DB/Redis/Rabbit via Testcontainers or Docker Compose
- E2E: Playwright against running stack (local compose or staging)
- Coverage gate: ≥80% line coverage on
backend/src/main/andworkers/
Spring Boot Testing
Unit Test Conventions
- Use
@ExtendWith(MockitoExtension.class)— no Spring context load - Mock all dependencies with
@Mock, inject with@InjectMocks - Name pattern:
MethodName_StateUnderTest_ExpectedBehavior - One assertion concept per test (multiple
assertThaton same result OK) - Never use
Thread.sleep— use@Timeoutor Awaitility
@ExtendWith(MockitoExtension.class)
class TrackServiceTest {
@Mock TrackRepository trackRepository;
@Mock MinioStorageService storageService;
@InjectMocks TrackService trackService;
@Test
void getTrack_WhenNotFound_ThrowsTrackNotFoundException() {
when(trackRepository.findById(any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> trackService.getTrack(UUID.randomUUID()))
.isInstanceOf(TrackNotFoundException.class);
}
}
Web Layer Tests (MockMvc)
- Use
@WebMvcTest(XController.class)— loads only web layer - Mock service beans with
@MockBean - Use
MockMvcRequestBuilders+MockMvcResultMatchers - Test auth: include valid JWT via helper, test 401 without token
@WebMvcTest(TrackController.class)
@Import(SecurityConfig.class)
class TrackControllerTest {
@Autowired MockMvc mockMvc;
@MockBean TrackService trackService;
@Test
void getTrack_WithValidAuth_Returns200() throws Exception {
given(trackService.getTrack(any())).willReturn(trackDto());
mockMvc.perform(get("/tracks/{id}", UUID.randomUUID())
.header("Authorization", "Bearer " + validJwt()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Test Track"));
}
@Test
void getTrack_WithoutAuth_Returns401() throws Exception {
mockMvc.perform(get("/tracks/{id}", UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
}
Internal Endpoint Tests (API Key)
@WebMvcTest(InternalTrackController.class)
class InternalTrackControllerTest {
@Autowired MockMvc mockMvc;
@MockBean TrackService trackService;
@Test
void updateStatus_WithValidApiKey_Returns200() throws Exception {
mockMvc.perform(patch("/internal/tracks/{id}/status", UUID.randomUUID())
.header("X-Api-Key", "test-worker-key")
.contentType(APPLICATION_JSON)
.content("{\"status\":\"READY\"}"))
.andExpect(status().isOk());
}
@Test
void updateStatus_WithoutApiKey_Returns403() throws Exception {
mockMvc.perform(patch("/internal/tracks/{id}/status", UUID.randomUUID())
.contentType(APPLICATION_JSON)
.content("{\"status\":\"READY\"}"))
.andExpect(status().isForbidden());
}
@Test
void updateStatus_WithJwtInsteadOfApiKey_Returns403() throws Exception {
mockMvc.perform(patch("/internal/tracks/{id}/status", UUID.randomUUID())
.header("Authorization", "Bearer " + validJwt())
.contentType(APPLICATION_JSON)
.content("{\"status\":\"READY\"}"))
.andExpect(status().isForbidden());
}
}
Repository / Integration Tests (Testcontainers)
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Testcontainers
class TrackRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
}
@Autowired TrackRepository trackRepository;
@Test
void searchByQuery_ReturnRankedResults() {
// insert tracks, run FTS query, assert ranking order
}
}
RabbitMQ Integration Tests
@SpringBootTest
@Testcontainers
class UploadServiceIntegrationTest {
@Container
static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3-management");
@Autowired UploadService uploadService;
@Autowired RabbitTemplate rabbitTemplate;
@Test
void upload_PublishesTranscodeRequestedMessage() throws Exception {
uploadService.ingest(mockFile(), userId);
// poll queue or use CountDownLatch listener to verify message published
await().atMost(5, SECONDS)
.untilAsserted(() -> assertThat(capturedMessages).hasSize(1));
}
}
Rate Limit Tests
@Test
void login_ExceedRateLimit_Returns429() throws Exception {
for (int i = 0; i < 5; i++) {
mockMvc.perform(post("/auth/login").content(badCreds())).andReturn();
}
mockMvc.perform(post("/auth/login").content(badCreds()))
.andExpect(status().isTooManyRequests());
}
Auth Unit Tests
- Test JWT generation: issued claims match input, expiry correct
- Test JWT validation: expired token rejected, tampered signature rejected, missing token = 401
- Test refresh rotation: old refresh blacklisted in Redis after rotation
- Test BCrypt: password verify call is made (mock), plain text never stored
Python Worker Testing
pytest Configuration
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short --strict-markers"
[tool.coverage.run]
source = ["transcoder", "subtitler", "shared"]
omit = ["tests/*"]
[tool.coverage.report]
fail_under = 80
Unit Test Conventions
- One test file per source module:
test_ffmpeg.pyforffmpeg.py - Mock all I/O:
subprocess.run,httpx.AsyncClient,minio.AsyncClient - Never read real files — use
tmp_pathfixture for any filesystem tests - Never use real API keys — fixtures provide dummy values
# tests/conftest.py
import pytest
from unittest.mock import AsyncMock, patch
from shared.config import Settings
@pytest.fixture(autouse=True)
def mock_settings(monkeypatch):
monkeypatch.setenv("RABBITMQ_URL", "amqp://guest:guest@localhost/")
monkeypatch.setenv("MINIO_ENDPOINT", "localhost:9000")
monkeypatch.setenv("MINIO_ACCESS_KEY", "minioadmin")
monkeypatch.setenv("MINIO_SECRET_KEY", "minioadmin")
monkeypatch.setenv("SPRING_BASE_URL", "http://localhost:8080")
monkeypatch.setenv("WORKER_API_KEY", "test-key-not-real")
monkeypatch.setenv("MINIO_BUCKET", "test-bucket")
FFmpeg Wrapper Tests
# tests/test_ffmpeg.py
from unittest.mock import patch, MagicMock
from pathlib import Path
import pytest
from transcoder.ffmpeg import run_ffmpeg
def test_run_ffmpeg_success(tmp_path):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
run_ffmpeg(tmp_path / "input.mp3", tmp_path / "out", "track-uuid")
args = mock_run.call_args[0][0]
assert "ffmpeg" == args[0]
assert "shell=True" not in str(mock_run.call_args) # no shell
def test_run_ffmpeg_failure_raises(tmp_path):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr=b"error details")
with pytest.raises(RuntimeError, match="FFmpeg failed"):
run_ffmpeg(tmp_path / "input.mp3", tmp_path / "out", "track-uuid")
def test_run_ffmpeg_args_never_use_shell(tmp_path):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
run_ffmpeg(tmp_path / "input.mp3", tmp_path / "out", "track-uuid")
_, kwargs = mock_run.call_args
assert kwargs.get("shell", False) is False
VTT Formatter Tests
# tests/test_vtt.py
from subtitler.vtt import lines_to_vtt, ms_to_vtt_time
def test_ms_to_vtt_time_basic():
assert ms_to_vtt_time(0) == "00:00:00.000"
assert ms_to_vtt_time(61500) == "00:01:01.500"
assert ms_to_vtt_time(3_661_000) == "01:01:01.000"
def test_lines_to_vtt_format():
lines = [{"startMs": 0, "endMs": 2000, "text": "Hello"}]
vtt = lines_to_vtt(lines)
assert vtt.startswith("WEBVTT")
assert "00:00:00.000 --> 00:00:02.000" in vtt
assert "Hello" in vtt
def test_lines_to_vtt_empty():
vtt = lines_to_vtt([])
assert "WEBVTT" in vtt
Pydantic Schema Tests
# tests/test_models.py
import pytest
from pydantic import ValidationError
from shared.models import TranscodeRequestedMessage
def test_valid_message():
msg = TranscodeRequestedMessage(trackId="123e4567-e89b-12d3-a456-426614174000", rawKey="raw/abc")
assert str(msg.trackId) == "123e4567-e89b-12d3-a456-426614174000"
def test_invalid_uuid_rejected():
with pytest.raises(ValidationError):
TranscodeRequestedMessage(trackId="not-a-uuid", rawKey="raw/abc")
def test_missing_field_rejected():
with pytest.raises(ValidationError):
TranscodeRequestedMessage(trackId="123e4567-e89b-12d3-a456-426614174000")
SpringClient Tests (respx mock)
# tests/test_spring_client.py
import pytest
import respx
import httpx
from shared.spring_client import SpringClient
@pytest.fixture
def client():
return SpringClient()
@respx.mock
async def test_update_track_status_sends_api_key(client):
track_id = "123e4567-e89b-12d3-a456-426614174000"
route = respx.patch(f"http://localhost:8080/internal/tracks/{track_id}/status").mock(
return_value=httpx.Response(200)
)
await client.update_track_status(track_id, "READY")
assert route.called
assert route.calls[0].request.headers["x-api-key"] == "test-key-not-real"
@respx.mock
async def test_spring_4xx_raises(client):
respx.patch(url__regex=r"/internal/tracks/.*/status").mock(
return_value=httpx.Response(400)
)
with pytest.raises(httpx.HTTPStatusError):
await client.update_track_status("123e4567-e89b-12d3-a456-426614174000", "READY")
FastAPI Admin Endpoint Tests
# tests/test_admin_routes.py
import pytest
from httpx import AsyncClient, ASGITransport
from transcoder.main import app
@pytest.fixture
async def ac():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_health_returns_200(ac):
r = await ac.get("/health")
assert r.status_code == 200
async def test_admin_reprocess_requires_api_key(ac):
r = await ac.post("/admin/reprocess", json={"trackId": "uuid"})
assert r.status_code == 403
async def test_admin_reprocess_with_valid_key(ac):
r = await ac.post(
"/admin/reprocess",
json={"trackId": "123e4567-e89b-12d3-a456-426614174000"},
headers={"X-Api-Key": "test-key-not-real"},
)
assert r.status_code in (200, 202)
Playwright (E2E)
Setup
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['github']],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:8080',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'api', use: { ...devices['Desktop Chrome'] } },
],
});
API Testing via Playwright (no browser needed for backend E2E)
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test('register → login → access protected endpoint', async ({ request }) => {
const reg = await request.post('/auth/register', {
data: { username: 'testuser', email: 'test@example.com', password: 'Test1234' }
});
expect(reg.ok()).toBeTruthy();
const login = await request.post('/auth/login', {
data: { email: 'test@example.com', password: 'Test1234' }
});
expect(login.ok()).toBeTruthy();
const { accessToken } = await login.json();
const me = await request.get('/users/me', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(me.ok()).toBeTruthy();
});
test('login rate limit triggers 429 after 5 attempts', async ({ request }) => {
for (let i = 0; i < 5; i++) {
await request.post('/auth/login', { data: { email: 'x@x.com', password: 'wrong' } });
}
const r = await request.post('/auth/login', { data: { email: 'x@x.com', password: 'wrong' } });
expect(r.status()).toBe(429);
});
Upload + Transcoding E2E
// e2e/upload.spec.ts
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
test('upload mp3 → track becomes READY', async ({ request }) => {
const { accessToken } = await loginAs('uploader@test.com', request);
const mp3 = fs.readFileSync(path.join(__dirname, 'fixtures/sample.mp3'));
const upload = await request.post('/uploads', {
headers: { Authorization: `Bearer ${accessToken}` },
multipart: { file: { name: 'sample.mp3', mimeType: 'audio/mpeg', buffer: mp3 } }
});
expect(upload.status()).toBe(202);
const { trackId } = await upload.json();
// Poll until READY (max 60s for CI — transcoder must be running)
await expect.poll(async () => {
const r = await request.get(`/tracks/${trackId}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
return (await r.json()).status;
}, { timeout: 60_000, intervals: [2_000] }).toBe('READY');
});
Subtitle E2E
// e2e/subtitles.spec.ts
test('track subtitles generated after upload', async ({ request }) => {
const { accessToken } = await loginAs('uploader@test.com', request);
const trackId = await uploadAndWaitReady(request, accessToken);
await expect.poll(async () => {
const r = await request.get(`/tracks/${trackId}/subtitles`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
return (await r.json()).length;
}, { timeout: 120_000, intervals: [3_000] }).toBeGreaterThan(0);
});
Auth Security E2E
// e2e/security.spec.ts
test('internal endpoints blocked from JWT-authed users', async ({ request }) => {
const { accessToken } = await loginAs('user@test.com', request);
const r = await request.patch(`/internal/tracks/some-id/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
data: { status: 'READY' }
});
expect(r.status()).toBe(403);
});
test('internal endpoints blocked without any auth', async ({ request }) => {
const r = await request.patch(`/internal/tracks/some-id/status`, {
data: { status: 'READY' }
});
expect(r.status()).toBe(403);
});
CI Automation
GitHub Actions (test job sketch)
test-backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_DB: testdb, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
options: --health-cmd pg_isready
redis:
image: redis:7
rabbitmq:
image: rabbitmq:3
minio:
image: minio/minio
env: { MINIO_ROOT_USER: minioadmin, MINIO_ROOT_PASSWORD: minioadmin }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '25', distribution: temurin }
- run: ./mvnw verify -Pcoverage
- uses: actions/upload-artifact@v4
with: { name: surefire-reports, path: target/surefire-reports }
test-workers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install -r workers/transcoder/requirements.txt -r workers/subtitler/requirements.txt
- run: pytest workers/ --cov=workers --cov-report=xml --cov-fail-under=80
test-e2e:
needs: [test-backend, test-workers]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose up -d && sleep 10 # wait for stack
- uses: actions/setup-node@v4
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: playwright-report/ }
What to Test Per Feature
| Feature | Unit | Integration | E2E |
|---|---|---|---|
| Auth register/login | JWT claims, BCrypt call | DB insert, token roundtrip | Full register → login → protected |
| Auth rate limit | Bucket4j counter logic | Redis counter increment | 429 after 5 attempts |
| Track metadata | Service logic | DB CRUD | GET track returns correct shape |
| Search FTS | Query builder | Postgres FTS results, ranking | Search returns ranked results |
| Upload ingestion | File routing, RabbitMQ publish | MinIO store, message published | Upload → 202 |
| Transcoding worker | FFmpeg args, VTT format | FFmpeg + MinIO write | Track → READY |
| Subtitle worker | Whisper call mock, VTT format | Lines in DB | Subtitles appear after transcode |
| Playlist CRUD | Service logic | DB CRUD + copy | Create, add tracks, copy |
| Internal API key | Key header check | 403 without key | JWT users blocked from /internal/** |
Anti-Patterns to Prevent
Thread.sleepin tests — use Awaitility orexpect.poll- Hardcoded credentials in test code — always env/fixtures
- Testing implementation details — test behavior/contracts, not private methods
- Single mega integration test — decompose to unit + targeted integration
- Flaky E2E from missing waits — always explicit
waitFor/expect.poll - Mocking what you own when you could test it — only mock I/O boundaries (DB, HTTP, filesystem, queue)
shell=Truesubprocess in tests — assert it's never used in production code under test- Asserting log output contains secrets — never log secrets, so this should never be needed
Test Data Conventions
- Factories in
src/test/java/com/musicstream/backend/testutil/(Spring) andtests/factories.py(Python) - Randomized IDs in unit tests (
UUID.randomUUID(),uuid4()) - Fixed seeds for property-based tests (Hypothesis:
@settings(deriving=fixed(42))) - E2E fixtures:
e2e/fixtures/sample.mp3— short (3s) silent MP3, committed to repo - Never use production data in tests
What You Do NOT Own
- Production implementation code (flag issues, write tests, don't rewrite impl)
- Infrastructure provisioning (Docker Compose, Kubernetes)
- CI/CD pipeline deployment steps (only test steps)
- Android/Kotlin test code