CI for a Full-Stack Project: First Steps with Testing and GitHub Actions

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:

  1. unit-level logic
    Example: validation and data normalization
  2. API-level behavior
    Example: GET /api/papers, POST /api/papers, bad input handling
  3. 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 tests
  • vi: Vitest’s mocking system (used here to fake fetch)
  • afterEach: hook that runs after every test
  • render: mounts your React component in a virtual DOM
  • screen: queries what’s rendered (like a user reading the page)
  • waitFor: waits for async UI updates
  • cleanup: clears the DOM between tests
  • App: 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 another
  • vi.unstubAllGlobals() restores any mocked globals (like fetch)
    → 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

  1. Replace fetch with 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
  1. Render the component
render(<App />);
  1. 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

  1. 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
  1. Render the component
  2. Wait for the UI to update
await waitFor(...)

Because React updates asynchronously after the fetch resolves.

  1. 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

  1. Mock a failed request
Promise.reject(...)

So fetch throws an error.

  1. Render the component
  2. Wait for error handling to occur
  3. 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:

ScenarioWhat you simulateWhat you verify
Loadingfetch never resolvesloading UI
Successfetch returns datadata renders
Failurefetch throws errorerror 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 package.json if you want a clean top-level command:

{
"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 test runs 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:

  1. clean environment: CI runs on a fresh machine with a fresh checkout of your code, though dependency caches may be reused for speed.
  2. install dependencies
  3. run tests
  4. build frontend
  5. 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

Scroll to Top