test: add full application BDD suite
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
bed3f9a019
commit
79e09f049f
14 changed files with 1408 additions and 4 deletions
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -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 \
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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
2
bdd/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
reports
|
||||||
8
bdd/Dockerfile
Normal file
8
bdd/Dockerfile
Normal 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
9
bdd/cucumber.mjs
Normal 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,
|
||||||
|
};
|
||||||
86
bdd/features/recruitment-workspace.feature
Normal file
86
bdd/features/recruitment-workspace.feature
Normal 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
1082
bdd/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
bdd/package.json
Normal file
13
bdd/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
93
bdd/steps/workspace.steps.js
Normal file
93
bdd/steps/workspace.steps.js
Normal 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
24
bdd/support/hooks.js
Normal 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
58
docker-compose.bdd.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue