test: add full application BDD suite

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 09:13:48 +02:00
parent bed3f9a019
commit 79e09f049f
14 changed files with 1408 additions and 4 deletions

View file

@ -9,6 +9,7 @@
**/node_modules **/node_modules
frontend/dist frontend/dist
frontend/coverage frontend/coverage
bdd/reports
**/*.tsbuildinfo **/*.tsbuildinfo
backend/bin backend/bin

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ backend/api
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/coverage/ frontend/coverage/
bdd/node_modules/
bdd/reports/

View file

@ -3,8 +3,9 @@ SHELL := /bin/sh
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
JWT_SECRET ?= local-development-secret-change-me-12345 JWT_SECRET ?= local-development-secret-change-me-12345
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
BDD_COMPOSE := docker compose -f docker-compose.bdd.yml
.PHONY: dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-env test-env-down lint build .PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build
help: help:
@echo "make dev builds the demo docker image" @echo "make dev builds the demo docker image"
@ -16,10 +17,12 @@ help:
@echo "make backend runs the backend" @echo "make backend runs the backend"
@echo "make frontend runs the frontend" @echo "make frontend runs the frontend"
@echo "make frontend-install installs the dependencies for the frontend" @echo "make frontend-install installs the dependencies for the frontend"
@echo "make test full test suite: test-backend test-frontend and test-integration" @echo "make test full test suite: frontend, integration, and browser BDD"
@echo "make test-backend runs the tests for the backend" @echo "make test-backend runs the tests for the backend"
@echo "make test-frontend runs the tests for the fronted" @echo "make test-frontend runs the tests for the fronted"
@echo "make test-integration runs the integration tests" @echo "make test-integration runs the integration tests"
@echo "make test-bdd runs browser BDD stories against the production image"
@echo "make test-bdd-down removes the isolated BDD stack and database"
@echo "make test-env builds the docker tests image" @echo "make test-env builds the docker tests image"
@echo "make test-env-down stops the test image" @echo "make test-env-down stops the test image"
@echo "make lint runs lint on the frontend" @echo "make lint runs lint on the frontend"
@ -51,7 +54,7 @@ frontend:
frontend-install: frontend-install:
cd frontend && npm ci cd frontend && npm ci
test: frontend-install test-integration test-frontend test: frontend-install test-integration test-frontend test-bdd
test-backend: test-backend:
cd backend && go test ./... cd backend && go test ./...
@ -62,6 +65,15 @@ test-frontend:
test-integration: test-integration:
cd backend && go test -tags=integration ./... cd backend && go test -tags=integration ./...
test-bdd:
@set -eu; \
$(BDD_COMPOSE) down -v --remove-orphans; \
trap '$(BDD_COMPOSE) down -v --remove-orphans >/dev/null 2>&1' EXIT INT TERM; \
$(BDD_COMPOSE) up --build --abort-on-container-exit --exit-code-from bdd
test-bdd-down:
$(BDD_COMPOSE) down -v --remove-orphans
test-env: test-env:
COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \ COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \
WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 RABBITMQ_AMQP_PORT=15673 \ WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 RABBITMQ_AMQP_PORT=15673 \

View file

@ -59,6 +59,20 @@ Run one frontend test:
cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title" cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title"
``` ```
Run the browser-level BDD user stories against an isolated production image:
```bash
make test-bdd
```
The human-readable scenarios live in
`bdd/features/recruitment-workspace.feature`. Add, remove, or edit scenarios in
that single file; reusable Playwright-backed steps live under `bdd/steps`.
Every run creates a fresh PostgreSQL database, loads deterministic test fixtures,
starts the complete production container with RabbitMQ, and writes an HTML report
to `bdd/reports/cucumber.html`. Use `make test-bdd-down` to remove a manually
started BDD stack.
Docker must be available for `make test`. The integration suite starts PostgreSQL Docker must be available for `make test`. The integration suite starts PostgreSQL
18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration, 18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration,
loads deterministic test fixtures, exercises login and the complete assessment and loads deterministic test fixtures, exercises login and the complete assessment and

2
bdd/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
reports

8
bdd/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM mcr.microsoft.com/playwright:v1.63.0-noble
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "test"]

9
bdd/cucumber.mjs Normal file
View file

@ -0,0 +1,9 @@
export default {
paths: ["features/**/*.feature"],
import: ["support/**/*.js", "steps/**/*.js"],
format: ["progress", "html:reports/cucumber.html"],
formatOptions: {
snippetInterface: "async-await",
},
parallel: 1,
};

View file

@ -0,0 +1,86 @@
Feature: Recruitment workspace
As an interview team
We want one connected workspace for jobs, candidates, assessments, and interviews
So that we can move qualified people through a consistent hiring process
Scenario: User story - a valid interviewer signs in
Given the Go Guess application is open
When I sign in with email "integration@example.com" and password "integration-password"
Then I should see the heading "Your hiring pipeline, at a glance."
And I should be on the "/" page
Scenario: User story - invalid credentials are rejected
Given the Go Guess application is open
When I sign in with email "integration@example.com" and password "incorrect-password"
Then I should see the error "invalid email or password"
And I should see the heading "Sign in to your workspace"
Scenario: User story - an interviewer gets a quick pipeline overview
Given I am signed in as a valid interviewer
Then I should see the heading "Your hiring pipeline, at a glance."
And the dashboard should link to jobs, participants, and questions
And I should see "Integration Backend Engineer"
Scenario: User story - an interviewer creates a job posting
Given I am signed in as a valid interviewer
When I follow the "Jobs" link
And I follow the "Create job" link
And I fill in the form:
| Job title | BDD Platform Engineer |
| Position | Platform Engineer |
| Seniority | Senior |
| Labels | Backend |
| Description | Build reliable production infrastructure. |
| Required skills | Go, PostgreSQL |
| Additional skills | Docker |
And I click the "Create job" button
Then I should see the heading "BDD Platform Engineer"
And I should see "Build reliable production infrastructure."
And I should see "draft"
Scenario: User story - an interviewer adds a participant
Given I am signed in as a valid interviewer
When I follow the "Participants" link
And I follow the "Add participant" link
And I fill in the form:
| First name | Beatrice |
| Last name | Driven |
| Birthday | 1993-06-14 |
| Email address | beatrice.bdd@example.com |
| Contact information | Brussels, +32 470 11 22 33 |
And I click the "Create participant" button
Then I should see the heading "Beatrice Driven"
And I should see "beatrice.bdd@example.com"
Scenario: User story - an interviewer creates a reusable assessment question
Given I am signed in as a valid interviewer
When I follow the "Question library" link
And I fill in the form:
| Question prompt | How do you keep a deployment reversible? |
| Reference answer | Use versioned artifacts and tested rollback procedures. |
And I click the "Create question" button
Then I should see "How do you keep a deployment reversible?"
And I should see "Use versioned artifacts and tested rollback procedures."
Scenario: User story - an interviewer invites a matching candidate
Given I am signed in as a valid interviewer
When I follow the "Jobs" link
And I follow the "Integration Backend Engineer" link
And I select the "Candidate match" tab
Then I should see "Integration Candidate"
And I should see "100%"
When I select the "Invitations" tab
And I click the "Generate invitation" button
Then an invitation should be shown for "Integration Candidate"
Scenario: User story - an interviewer can reach collaboration areas
Given I am signed in as a valid interviewer
When I follow the "Inbox" link
Then I should see the heading "Inbox"
And I should see "Inbox zero"
When I follow the "Calendar" link
Then I should be on the "/calendar" page
And I should see "Schedule"
When I follow the "Users" link
Then I should see the heading "Interview team"
And I should see "Integration Interviewer"

1082
bdd/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

13
bdd/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "go-guess-bdd",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "cucumber-js --config cucumber.mjs"
},
"devDependencies": {
"@cucumber/cucumber": "13.2.1",
"playwright": "1.63.0"
}
}

View file

@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
async function openLoginPage(world) {
await world.page.goto(world.baseURL);
await world.page
.getByRole("heading", { name: "Sign in to your workspace", exact: true })
.waitFor();
}
async function signIn(world, email, password) {
await world.page.getByLabel("Email address", { exact: true }).fill(email);
await world.page.getByLabel("Password", { exact: true }).fill(password);
await world.page
.getByRole("button", { name: "Sign in", exact: true })
.click();
}
Given("the Go Guess application is open", async function () {
await openLoginPage(this);
});
Given("I am signed in as a valid interviewer", async function () {
await openLoginPage(this);
await signIn(this, "integration@example.com", "integration-password");
await this.page
.getByRole("heading", {
name: "Your hiring pipeline, at a glance.",
exact: true,
})
.waitFor();
});
When(
"I sign in with email {string} and password {string}",
async function (email, password) {
await signIn(this, email, password);
},
);
When("I follow the {string} link", async function (name) {
await this.page.getByRole("link", { name, exact: true }).first().click();
});
When("I click the {string} button", async function (name) {
await this.page.getByRole("button", { name, exact: true }).click();
});
When("I select the {string} tab", async function (name) {
await this.page.getByRole("tab", { name: new RegExp(`^${name}`) }).click();
});
When("I fill in the form:", async function (table) {
for (const [label, value] of table.raw()) {
await this.page.getByLabel(label, { exact: true }).fill(value);
}
});
Then("I should see the heading {string}", async function (heading) {
await this.page
.getByRole("heading", { name: heading, exact: true })
.waitFor();
});
Then("I should see {string}", async function (text) {
await this.page.getByText(text, { exact: true }).first().waitFor();
});
Then("I should see the error {string}", async function (message) {
await this.page.getByRole("alert").filter({ hasText: message }).waitFor();
});
Then("I should be on the {string} page", async function (path) {
await this.page.waitForURL((url) => url.pathname === path);
assert.equal(new URL(this.page.url()).pathname, path);
});
Then(
"the dashboard should link to jobs, participants, and questions",
async function () {
for (const path of ["/jobs", "/participants", "/questions"]) {
await this.page.locator(`a[href="${path}"]`).first().waitFor();
}
},
);
Then("an invitation should be shown for {string}", async function (candidate) {
await this.page.getByText(candidate, { exact: true }).last().waitFor();
const invitationURL = await this.page
.locator(".invitation-row code")
.textContent();
assert.match(invitationURL ?? "", /\/participant\/[a-zA-Z0-9_-]+/);
});

24
bdd/support/hooks.js Normal file
View file

@ -0,0 +1,24 @@
import { After, Before, Status, setDefaultTimeout } from "@cucumber/cucumber";
import { chromium } from "playwright";
setDefaultTimeout(Number(process.env.BDD_STEP_TIMEOUT_MS ?? 15000));
Before(async function () {
this.browser = await chromium.launch({
headless: process.env.BDD_HEADLESS !== "false",
});
this.context = await this.browser.newContext();
this.page = await this.context.newPage();
this.baseURL = process.env.BDD_BASE_URL ?? "http://127.0.0.1:8080";
});
After(async function ({ result }) {
if (result?.status === Status.FAILED && this.page) {
await this.attach(
await this.page.screenshot({ fullPage: true }),
"image/png",
);
}
await this.context?.close();
await this.browser?.close();
});

58
docker-compose.bdd.yml Normal file
View file

@ -0,0 +1,58 @@
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: go_guess
POSTGRES_USER: go_guess
POSTGRES_PASSWORD: go_guess
healthcheck:
test: ["CMD-SHELL", "pg_isready -U go_guess -d go_guess"]
interval: 2s
timeout: 3s
retries: 30
rabbitmq:
image: rabbitmq:4-alpine
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 3s
timeout: 5s
retries: 30
app:
build:
context: .
dockerfile: Dockerfile
environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters
FRONTEND_URL: http://app:8080
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
APP_ENV: test
ports:
- "${BDD_WEB_PORT:-15174}:8080"
depends_on:
db:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/api/health"]
interval: 3s
timeout: 3s
retries: 30
bdd:
build:
context: bdd
dockerfile: Dockerfile
environment:
BDD_BASE_URL: http://127.0.0.1:8080
BDD_HEADLESS: "true"
BDD_STEP_TIMEOUT_MS: "15000"
network_mode: service:app
volumes:
- ./bdd/reports:/work/reports
depends_on:
app:
condition: service_healthy

View file

@ -13,7 +13,7 @@ seed
api & api &
api_pid=$! api_pid=$!
nginx -g 'daemon off;' & nginx -e /dev/stderr -g 'daemon off;' &
nginx_pid=$! nginx_pid=$!
trap shutdown TERM INT trap shutdown TERM INT