Goal for Today
By the end of class, we want this example project to have:
- a repeatable local/server test environment
- a single command that runs the whole test suite
- backend tests
- frontend tests
- one basic end-to-end smoke test
- a GitHub Actions workflow that runs those tests automatically on push
We are not starting with deployment yet.
We are starting with Continuous Integration: every meaningful change should be checked automatically.
What this example project already gives us
This project is a good example because it already has the three pieces:
- a React frontend built with Vite
- an Express backend
- a MariaDB database-backed CRUD route for papers
That means our tests need to cover three levels:
- unit-level logic
Example: validation and data normalization - API-level behavior
Example:GET /api/papers,POST /api/papers, bad input handling - whole-app smoke testing
Example: does the page load, and can the UI talk to the backend?
That layering matters. Fast tests catch mistakes early. Slower browser tests catch wiring problems. The whole point of CI is to run the right checks in the right order.
The testing stack
We will use these tools:
Backend
- Vitest for test runner
- Supertest for HTTP route testing
- small refactors so validation logic can be tested directly
Frontend
- Vitest
- React Testing Library
- jsdom
End-to-end
- Playwright
This is a very practical combination for a modern React + Express app. GitHub Actions can then run all of it in a workflow file checked into the repo. GitHub documents workflows as YAML files in the repository, and GitLab does the same with .gitlab-ci.yml; Jenkins uses a Jenkinsfile, but that comes with more setup burden.
Why not start with deployment?
Because if there is no reliable test command yet, CI becomes shallow theater.
Sequence:
working app → repeatable tests → single test command → CI runner → later CD
If we skip the middle, we end up with a pipeline that looks impressive but proves very little.
In class process
Part 1 — Prepare the project for testing
We will add the test libraries and test scripts.
Part 2 — Create backend tests
We will begin with the most stable logic:
- validation
- normalization
- one or two route tests
Part 3 — Create frontend tests
We will test:
- loading state
- error state
- paper table rendering
Part 4 — Add one smoke test
We will open the app in a browser and confirm the main page loads.
Part 5 — Add GitHub Actions
We will make the same checks run automatically on push.
Step 1: Make sure the project structure is clean
Before adding tests, we should confirm the project is in a good shape.
Expected structure:
/client
/server
/server/.env
/schema.sql
The server should already run, and the frontend should already build.
On the Linux server, verify:
cd client
npm install
npm run build
cd ../server
npm install
npm start
Also verify the backend route works:
curl http://10.192.145.179:41xx/api/status
and:
curl http://10.192.145.179:41xx/api/papers
Replace 41xx with the project’s real port.
Add .gitignore in the project root:
# Node dependencies
node_modules/
server/node_modules/
client/node_modules/
# Build output
client/dist/
# Playwright output
test-results/
playwright-report/
# Environment files
.env
server/.env
# Logs
npm-debug.log*
Step 2: Add the testing libraries
Backend dependencies
From server/:
npm install -D vitest supertest
Frontend dependencies
From client/:
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
End-to-end dependency
You can install Playwright at the project root if you want one central E2E setup, or in the client if you want to keep it closer to the UI. I prefer root-level for whole-app smoke tests.
From the project root:
npm install -D @playwright/test
npx playwright install
Step 3: Add test scripts
In server/package.json
Add:
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js",
"test": "vitest run",
"test:watch": "vitest"
}
In client/package.json
Add:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}
Step 4: Make backend code more testable
If code is hard to test, that often means it is too tightly coupled.
Right now, the papers route mixes together:
- normalization
- validation
- database access
- route handling
That is workable for a small app, but for testing we should separate the pure logic.
Create a new file
server/utils/paperValidation.js
Example:
export const ALLOWED_STATUS = ['to_read', 'reading', 'read'];
export function normalizePaper(body) {
return {
title: body.title?.trim() || '',
authors: body.authors?.trim() || null,
year:
body.year === '' || body.year === undefined || body.year === null
? null
: Number(body.year),
url: body.url?.trim() || null,
status: body.status?.trim() || 'to_read',
tags: body.tags?.trim() || null,
summary: body.summary?.trim() || null,
notes: body.notes?.trim() || null,
date_read:
body.date_read === '' || body.date_read === undefined || body.date_read === null
? null
: body.date_read
};
}
export function validatePaper(paper) {
const errors = {}; if (!paper.title) {
errors.title = 'Title is required.';
} if (
paper.year !== null &&
(!Number.isInteger(paper.year) || paper.year < 0 || paper.year > 3000)
) {
errors.year = 'Year must be a whole number between 0 and 3000.';
} if (!ALLOWED_STATUS.includes(paper.status)) {
errors.status = `Status must be one of: ${ALLOWED_STATUS.join(', ')}`;
} return errors;
}
Then import those functions into server/routes/papers.js.
CI gets easier when code is modular.
Step 5: Write the first backend unit tests
Create:
server/tests/paperValidation.test.js
import { describe, it, expect } from 'vitest';
import { normalizePaper, validatePaper } from '../utils/paperValidation.js';
describe('normalizePaper', () => {
it('trims strings and converts blank values to null', () => {
const result = normalizePaper({
title: ' Test Paper ',
authors: ' Jane Doe ',
year: '',
url: ' ',
status: 'reading',
tags: '',
summary: '',
notes: ' some notes ',
date_read: ''
});
expect(result).toEqual({
title: 'Test Paper',
authors: 'Jane Doe',
year: null,
url: null,
status: 'reading',
tags: null,
summary: null,
notes: 'some notes',
date_read: null
});
});
});describe('validatePaper', () => {
it('requires a title', () => {
const errors = validatePaper({
title: '',
year: 2024,
status: 'to_read'
}); expect(errors.title).toBe('Title is required.');
}); it('rejects invalid year', () => {
const errors = validatePaper({
title: 'Good Paper',
year: 4000,
status: 'to_read'
}); expect(errors.year).toBe('Year must be a whole number between 0 and 3000.');
}); it('rejects invalid status', () => {
const errors = validatePaper({
title: 'Good Paper',
year: 2024,
status: 'done'
}); expect(errors.status).toMatch(/Status must be one of/);
}); it('accepts valid paper data', () => {
const errors = validatePaper({
title: 'Good Paper',
year: 2024,
status: 'reading'
}); expect(errors).toEqual({});
});
});
Run:
cd server
npm test
Step 6: Add backend API tests
For Today, we will not try to test MariaDB directly first.
Small refactor for route testing
Export the app separately from the server start logic.
Create server/app.js
Put this in it:
// server/app.js
import dotenv from 'dotenv';
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import papersRouter from './routes/papers.js';
import pool from './db.js';
dotenv.config();
const app = express();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clientDistPath = path.join(__dirname, '..', 'client', 'dist'); app.use(express.json()); app.get('/api/hello', (req, res) => {
res.json({
message: 'Hello from the Express backend!'
});
}); app.get('/api/status', async (req, res) => {
try {
const [rows] = await pool.query('SELECT NOW() AS server_time'); res.json({
status: 'ok',
time: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
databaseTime: rows[0].server_time
});
} catch (error) {
console.error('Status route DB error:', error);
res.status(500).json({
status: 'error',
error: 'Database connection failed.'
});
}
}); app.use('/api/papers', papersRouter); app.use(express.static(clientDistPath)); app.get('*', (req, res) => {
res.sendFile(path.join(clientDistPath, 'index.html'));
});
export default app;
Then replace server/index.js with this
// server/index.js
import app from './app.js';const PORT = process.env.PORT || 4000;app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
app setup and app startup should be separate.
Example API test
Create:
server/tests/papersRoutes.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest'; vi.mock('../db.js', () => {
return {
default: {
query: vi.fn()
}
};
});
import app from '../app.js';
import pool from '../db.js';
describe('GET /api/papers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns papers', async () => {
pool.query.mockResolvedValueOnce([[
{ id: 1, title: 'Paper A', status: 'to_read' },
{ id: 2, title: 'Paper B', status: 'reading' }
]]);
const response = await request(app).get('/api/papers'); expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].title).toBe('Paper A');
});
it('handles database errors', async () => {
pool.query.mockRejectedValueOnce(new Error('DB failed'));
const response = await request(app).get('/api/papers');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Failed to fetch papers.');
});
});
Step 7: Add frontend test setup
Create:
client/src/test/setup.js
import '@testing-library/jest-dom/vitest';
Then create or edit client/vite.config.js to include test config:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:4101'
}
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.js'
}
});
Step 8: Write the first frontend tests
Create:
client/src/App.test.jsx
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import App from './App';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('App', () => {
it('shows loading initially', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
render(<App />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('renders papers after fetch succeeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve([
{
id: 1,
title: 'Testing Paper',
authors: 'A. Author',
year: 2024,
status: 'reading'
}
])
})
)
);
render(<App />);
await waitFor(() => {
expect(screen.getByText('Testing Paper')).toBeInTheDocument();
});
});
it('shows error if fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
);
render(<App />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/could not load papers/i);
});
});
});
Run:
cd client
npm test
What this does:
1. Imports — what tools are in play
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import App from './App';
describe,it,expect: define and assert testsvi: Vitest’s mocking system (used here to fakefetch)afterEach: hook that runs after every testrender: mounts your React component in a virtual DOMscreen: queries what’s rendered (like a user reading the page)waitFor: waits for async UI updatescleanup: clears the DOM between testsApp: the component under test
2. Cleanup after each test
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
Two things happen after every test:
cleanup()removes the rendered component from the DOM
→ prevents one test from affecting anothervi.unstubAllGlobals()restores any mocked globals (likefetch)
→ ensures each test starts with a clean environment
This is what keeps tests independent and repeatable.
3. Test suite definition
describe('App', () => {
...
});
This groups all tests related to the App component.
4. Test 1 — “shows loading initially”
it('shows loading initially', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); render(<App />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
What’s happening
- Replace
fetchwith a fake version
new Promise(() => {})
This creates a promise that never resolves.
So:
- the app calls
fetch - the request never finishes
- the app stays in its “loading” state forever
- Render the component
render(<App />);
- Check what the user sees
expect(screen.getByText(/loading/i)).toBeInTheDocument();
This verifies:
While waiting for data, the UI shows a loading message.
5. Test 2 — “renders papers after fetch succeeds”
it('renders papers after fetch succeeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve([
{ id: 1, title: 'Testing Paper', ... }
])
})
)
); render(<App />); await waitFor(() => {
expect(screen.getByText('Testing Paper')).toBeInTheDocument();
});
});
What’s happening
- Mock a successful API response
You simulate what a real server would return:
{
ok: true,
json: () => Promise.resolve([...])
}
So when App calls fetch, it receives:
- a successful response
- with one paper
- Render the component
- Wait for the UI to update
await waitFor(...)
Because React updates asynchronously after the fetch resolves.
- Check the result
screen.getByText('Testing Paper')
This verifies:
When the API succeeds, the paper appears in the UI.
6. Test 3 — “shows error if fetch fails”
it('shows error if fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
); render(<App />); await waitFor(() => {
expect(screen.getByRole('alert'))
.toHaveTextContent(/could not load papers/i);
});
});
What’s happening
- Mock a failed request
Promise.reject(...)
So fetch throws an error.
- Render the component
- Wait for error handling to occur
- Check what the user sees
getByRole('alert')
This targets a semantic UI element (Bootstrap alert).
This verifies:
If the network fails, the app shows an error message.
7. What this test suite is really doing
Across the three tests, you are controlling the three possible states of the app:
| Scenario | What you simulate | What you verify |
|---|---|---|
| Loading | fetch never resolves | loading UI |
| Success | fetch returns data | data renders |
| Failure | fetch throws error | error message |
So you are testing:
“Given any possible outcome of the API call, the UI behaves correctly.”
8. Why this approach works
- No real backend is needed
- Tests are fast and deterministic
- You control every scenario precisely
This is a component-level test with mocked integration.
9. The deeper idea
You are not testing how the app works internally.
You are testing:
“If the world behaves like this, what does the user see?”
That’s the right level of abstraction for frontend tests.
One sentence summary
This file replaces the network with controlled fake responses and checks that the UI shows the correct loading, success, and error states.
Step 9: Add one whole-app smoke test with Playwright
The point here is not deep browser automation yet.
The point is to catch obvious breakage.
Create in project root:
playwright.config.js
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://127.0.0.1:4101'
},
webServer: {
command: 'cd client && npm run build && cd ../server && npm start',
url: 'http://127.0.0.1:4101',
reuseExistingServer: true,
timeout: 120 * 1000
}
});
Then create:
tests/smoke.spec.js
import { test, expect } from '@playwright/test';
test('main page loads', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toContainText(/paper|loading|papers/i);
});
Add a root if you want a clean top-level command:package.json
{
"name": "papers-project-root",
"private": true,
"scripts": {
"test:server": "cd server && npm test",
"test:client": "cd client && npm test",
"test:e2e": "playwright test",
"test": "npm run test:server && npm run test:client && npm run test:e2e"
},
"devDependencies": {
"@playwright/test": "^1.54.0"
}
}
Then run:
npm test
Summery: “This file tells Playwright how to boot the entire app and where to point the browser once it’s running.”
Step 10: Add GitHub Actions
GitHub Actions workflows are YAML files checked into .github/workflows/, and they can run automatically on push or pull request.
Create:
.github/workflows/ci.yml
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
client/package-lock.json
server/package-lock.json
package-lock.json
- name: Install root dependencies
run: |
if [ -f package.json ]; then npm install; fi
- name: Install client dependencies
run: |
cd client
npm install
- name: Install server dependencies
run: |
cd server
npm install
- name: Run server tests
run: |
cd server
npm test
- name: Run client tests
run: |
cd client
npm test
- name: Build client
run: |
cd client
npm run build
- name: Install Playwright browsers
run: |
npx playwright install --with-deps
- name: Run end-to-end smoke test
run: |
npm run test:e2e
That is enough for a first working pipeline.
Step 11: Trigger the CI Pipeline
At this point, everything should already work locally:
npm testruns all tests- frontend builds
- Playwright smoke test passes
Now we connect that to GitHub.
Commit and push your changes
git add .
git commit -m "Add CI pipeline with GitHub Actions"
git push
What happens next
As soon as you push:
- GitHub detects
.github/workflows/ci.yml - it starts a workflow run
- it executes all steps in a fresh Linux environment
Step 12: Watch the Pipeline Run
Go to your repository in GitHub.
Click:
Actions tab
You should see a workflow run labeled “CI”.
Click into it and walk through the steps with the class:
- checkout
- install dependencies
- server tests
- client tests
- build
- Playwright
“This is just doing exactly what we were doing manually — but now it runs automatically on every push.”
Step 13: What a Failure Looks Like (and Why That’s Good)
Now deliberately break something.
For example:
// in paperValidation.js
if (!paper.title) {
errors.title = 'Title is required.';
}
Change it to:
if (false) {
errors.title = 'Title is required.';
}
Then:
git add .
git commit -m "Break validation intentionally"
git push
In GitHub Actions:
- the pipeline will fail
- the failing test will be highlighted
CI is not about success — it is about catching mistakes immediately.
Step 14: Fix and Re-run
Fix the code and push again:
git add .
git commit -m "Fix validation"
git push
The pipeline should turn green again.
This reinforces:
- fast feedback loop
- confidence in changes
- discipline before committing
Step 15: What the Pipeline Is Actually Doing
The CI pipeline is doing:
- clean environment: CI runs on a fresh machine with a fresh checkout of your code, though dependency caches may be reused for speed.
- install dependencies
- run tests
- build frontend
- run smoke test
That means:
If CI passes, your project works from scratch.
That is a much stronger guarantee than “it worked on my machine.”
Step 16: Why the Playwright Config Matters in CI
Our current config:
webServer: [
{
command: 'cd client && npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
port: 4173,
reuseExistingServer: true
},
{
command: 'cd server && npm start',
port: 4000,
reuseExistingServer: true
}
]
This is doing something subtle but important:
- it starts both frontend and backend automatically
- it waits until they are ready
- then runs tests
Explain this clearly:
“Our CI is not faking anything — it is actually running the full app.”
That’s why the smoke test is meaningful.
Step 17: A Clean Mental Model
Local workflow
edit → test → test → test → commit
CI workflow
push → GitHub runs the same tests → pass/fail
Same process — just automated.
Step 18: What “Done” Means Now
Update the definition of “done”:
Before:
“It looks right in the browser.”
Now:
“It passes locally AND CI passes.”
Be explicit:
- UI working is not enough
- tests passing is not enough
- CI passing is the final check
Step 19: Common CI Issues (You Will Hit These)
1. “Works locally but fails in CI”
Usually:
- missing dependency
- environment assumption
- port mismatch
- timing issue
2. Playwright timeout
Fix by increasing timeout in config:
timeout: 30000
3. Server not starting fast enough
Fix with:
reuseExistingServer: false
or adding wait logic (later topic).
4. Missing root package.json
If npm run test:e2e fails, CI may not see the script.
Step 20: What We Are Not Doing Yet
Be explicit about scope.
We are not yet doing:
- deployment
- database containers
- test databases
- secrets management
- multi-environment pipelines
Those come later.
Right now, we have:
a real, working CI pipeline that validates a full-stack app
That is already a big step.
Step 21: Short In-Class Wrap-Up
End with this:
“CI is not a tool. It is a habit that we automate.”
And then:
“Everything we just did manually — GitHub now enforces for us.”
Optional: One Final Prompt to Tie It Together
Try this:
Explain, step by step, what my GitHub Actions workflow is doing.
Relate each step to the commands I run locally.
Do not generalize — refer directly to my ci.yml file.
This helps to connect what is happening:
- actual code
- local workflow
- CI workflow
