Appearance
Lesson 2 — Continuous Integration & Continuous Deployment (CI/CD)
In Lesson 1, you created a Node.js/Express API, pushed it to GitHub, and deployed it on Render. Every time you change the application, you need to know whether it still works.
You could test it manually before each push, but that is easy to forget. In this lesson, you will add automated tests and make GitHub run them when you push code or open a pull request.
Your goal is to build a repeatable process that answers a simple question:
Is this change safe enough to move to the next step?
Continue with your Lesson 1 project
Use the deployment-workshop repository you created previously. Keep the Netlify website and the Render API; you do not need to create new hosting services for this lesson.
If your project already has more features than the Lesson 1 example, you can adapt the tests to your existing routes.
Learning outcomes
By the end of this lesson, you should be able to:
- Explain Continuous Integration, Continuous Delivery, and Continuous Deployment, and identify how they differ.
- Describe a CI workflow using events, jobs, runners, steps, and status checks.
- Write and run automated API tests using Vitest and Supertest.
- Configure a GitHub Actions workflow to install dependencies, validate code, and run tests automatically.
- Investigate a failed CI run and fix the problem.
- Explain how successful CI checks can act as a gate before an application is deployed on Render.
What you need
| Tool or account | Why you need it |
|---|---|
| Node.js 22.12+ and npm | Run the API and the current version of Vitest |
| Git and GitHub | Manage the repository and trigger workflows |
| GitHub Actions | Run automated checks on GitHub |
| Vitest | Write and execute tests |
| Supertest | Make HTTP requests to the Express app during tests |
| A code editor and terminal | Edit files and run commands |
| Render account (optional for the final activity) | Connect CI results to the API you deployed in Lesson 1 |
About the Node.js version
Run node --version to check your installation. Recent Vitest versions require Node.js 22.12 or newer. If your version is older, update Node.js before proceeding. Use the same major Node.js version locally and in GitHub Actions for consistent results.
What you will build
You will turn your GitHub repository into an automatically checked project:
text
Your computer
│
├── src/app.js ← Express routes
├── src/server.js ← Starts the HTTP server
├── tests/app.test.js ← Vitest + Supertest checks
└── .github/workflows/ci.yml
│
▼
GitHub
│
Push / Pull request
│
▼
GitHub Actions
│
Install → Check → Test
│
Pass or fail
│
▼
Optional: Render deploys
only after CI checks passImportant: The GitHub Actions workflow in this lesson performs CI only. It does not contain credentials or commands to deploy your app. Later in the course, you will build deployment automation. For now, Render can perform its own deployment once the tests pass.
Part 1 — Understand CI/CD
1. Why automate checks?
Imagine that two developers are working on the same API. One updates /health; the other adds a new endpoint. Their changes may work individually but cause problems when combined.
Continuous Integration (CI) is the practice of integrating code changes frequently and checking them automatically. A typical CI workflow:
- Retrieves a specific version of your source code.
- Installs the dependencies required by the project.
- Performs validation, such as syntax checks or a build.
- Runs automated tests.
- Reports whether the checks passed or failed.
Because the same checks run repeatedly, problems are easier to spot before changes reach users.
2. CI vs. Continuous Delivery vs. Continuous Deployment
These terms are related, but they do not mean exactly the same thing.
| Practice | What happens? | Example |
|---|---|---|
| Continuous Integration | Code is automatically checked as it changes. | GitHub runs Vitest on each pull request. |
| Continuous Delivery | Successfully checked code is kept ready for release, with deployment typically requiring an explicit approval or action. | A verified version is ready for someone to release. |
| Continuous Deployment | Changes that pass the required checks are automatically deployed without a separate manual release decision. | A merged change passes CI and is then deployed automatically. |
A simple model is:
text
Code change
│
▼
Continuous Integration
└── Install → Validate → Test
│
Checks pass
│
▼
Continuous Delivery
└── Version is ready to deploy
│
Manual approval?
/ \
Yes No
│ │
Manual release Automatic release
│
▼
Continuous DeploymentWhere does Render fit?
In Lesson 1, Render could redeploy your application after a push to main. That is an automatic deployment trigger, but it does not prove your changes passed automated tests.
In this lesson, you will add checks first. If you configure Render to wait for successful CI checks, you have a simple tested deployment path.
3. Understand a CI workflow
A GitHub Actions workflow uses several important concepts:
| Term | Meaning | Example in this lesson |
|---|---|---|
| Workflow | A YAML file describing an automation. | .github/workflows/ci.yml |
| Event / trigger | Something that starts a workflow. | A push to main or a pull request targeting main |
| Job | A group of steps run on the same runner. | The test job |
| Runner | The computer where a job executes. | A GitHub-hosted Ubuntu runner |
| Step | A command or reusable action within a job. | npm ci or npm test |
| Action | A reusable component in a workflow. | actions/checkout, actions/setup-node |
| Check result | The reported outcome of a job or workflow. | Success or failure on a commit / pull request |
Think about it
- Why should the checks run on GitHub instead of only on your laptop?
- Would a successful dependency installation prove that your API works?
- What should happen if a test fails?
Reveal suggested answers
- Everyone receives consistent feedback on the committed code, even if someone forgets to run the tests locally.
- No. Installing dependencies only confirms that the dependencies could be installed; it does not verify application behavior.
- The CI job should fail, the failure should be investigated, and the change should not be treated as verified.
Part 2 — Add Automated Tests with Vitest
4. Start with the API from Lesson 1
Open your deployment-workshop project and install its dependencies if needed:
bash
cd deployment-workshop
npm ciYour project should contain the Express server from Lesson 1:
text
deployment-workshop/
├── site/
│ └── index.html
├── src/
│ └── server.js
├── package.json
└── package-lock.jsonThe original src/server.js defines routes and starts listening for requests. That works for deployment, but it makes testing less convenient: importing the server file would also start the network listener.
You will separate those two responsibilities.
5. Separate the Express app from the listener
Create src/app.js:
js
import express from 'express';
const app = express();
app.use(express.json());
app.get('/', (_req, res) => {
res.json({ message: 'Hello from Render!' });
});
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
export default app;Replace src/server.js with:
js
import app from './app.js';
const port = Number(process.env.PORT || 3000);
app.listen(port, '0.0.0.0', () => {
console.log(`API listening on port ${port}`);
});Now the files have separate purposes:
src/app.jsdescribes the Express application and its routes.src/server.jsstarts the HTTP listener when you run the app.- Your tests can import
appwithout having to start and stop a long-running server process.
Check that you did not change the API's behavior:
bash
npm startOpen http://localhost:3000/health and confirm that you still receive:
json
{"status":"ok"}Stop the server with Ctrl+C before continuing.
Your Render configuration stays the same
You are still using npm start to run src/server.js, so the start command you configured in Lesson 1 does not need to change.
6. Install Vitest and Supertest
In the same project, run:
bash
npm install --save-dev vitest supertestBoth packages are development dependencies. They help you verify code while developing and do not need to run inside the production API process.
Add scripts to package.json:
bash
npm pkg set "scripts.test=vitest run"
npm pkg set "scripts.test:watch=vitest"
npm pkg set "scripts.check=node --check src/app.js && node --check src/server.js"Your scripts section should include these commands (alongside any scripts you already have):
json
{
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "vitest run",
"test:watch": "vitest",
"check": "node --check src/app.js && node --check src/server.js"
}
}Do not replace your entire package.json with the snippet above; it only shows the relevant scripts.
| Command | Purpose |
|---|---|
npm test | Run all tests once and return a pass/fail result. Ideal for CI. |
npm run test:watch | Watch files and rerun affected tests as you work. |
npm run check | Check the syntax of the two JavaScript source files. |
Why use vitest run in CI?
The interactive Vitest watch mode is helpful on your laptop, but a CI job must finish so GitHub can report a result. vitest run executes the tests once and exits.
7. Write your first API tests
Create a tests folder and a file named tests/app.test.js:
js
import { describe, expect, it } from 'vitest';
import request from 'supertest';
import app from '../src/app.js';
describe('GET /', () => {
it('returns the welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.body).toEqual({
message: 'Hello from Render!',
});
});
});
describe('GET /health', () => {
it('reports that the API is healthy', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });
});
it('returns a JSON response', async () => {
const response = await request(app).get('/health');
expect(response.headers['content-type']).toMatch(/json/);
});
});
describe('Unknown routes', () => {
it('returns 404 for an unknown URL', async () => {
const response = await request(app).get('/does-not-exist');
expect(response.status).toBe(404);
});
});Run the tests:
bash
npm testYou should see four passing tests.
How does this work?
js
const response = await request(app).get('/health');Supertest sends a request to the Express app in the test process. You do not need to run npm start in another terminal for these tests.
js
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });Vitest compares the actual response with the response you expected. If the values do not match, the test fails.
The tests above are API endpoint tests: they check how the application responds to HTTP requests. As your project grows, you can also write unit tests for individual functions without making HTTP requests.
8. See a failing test
A test suite is most useful when you know that it can detect a problem.
Temporarily change the expected health status in tests/app.test.js:
js
expect(response.body).toEqual({ status: 'offline' });Run:
bash
npm testOne test should now fail, because the real endpoint still returns {"status":"ok"}.
Change the assertion back to:
js
expect(response.body).toEqual({ status: 'ok' });Run npm test again. Confirm that everything passes before committing your changes.
Keep the deliberate failure out of main
For now, make and fix the intentional failure locally. Later, you can explore failing CI runs safely using a separate feature branch and pull request. Do not intentionally push broken application code to the branch that Render automatically deploys.
Your testing checkpoint
- [ ]
npm startstill starts the API. - [ ] I separated
src/app.jsfromsrc/server.js. - [ ]
npm testruns four API tests successfully. - [ ] I saw a test fail and then made it pass again.
- [ ]
npm run checkcompletes successfully.
Part 3 — Automate Testing with GitHub Actions
9. Create a CI workflow
GitHub Actions discovers workflow files inside the .github/workflows/ directory of your repository.
Create .github/workflows/ci.yml:
yaml
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test Express API
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Check JavaScript syntax
run: npm run check
- name: Run Vitest tests
run: npm testWorkflow file location
The .github directory belongs in your application repository, next to its package.json. Do not place this workflow inside the VitePress documentation website unless the documentation site is also your API repository.
10. Read the workflow step by step
When does it run?
yaml
on:
push:
branches: [main]
pull_request:
branches: [main]A workflow is triggered when you push to main or create/update a pull request targeting main. Other branches can run checks through their pull requests without being deployed.
Where does it run?
yaml
runs-on: ubuntu-latestGitHub provides a runner using Ubuntu. You do not need to keep your computer switched on.
What gets installed?
yaml
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npmThe checkout action retrieves the repository. The setup-node action selects Node.js 22 and enables npm's package download cache.
How does it check your work?
yaml
- run: npm ci
- run: npm run check
- run: npm testnpm ciinstalls dependencies frompackage-lock.json. It requires the lockfile to be committed and consistent withpackage.json.npm run checkvalidates JavaScript syntax. This example Express API does not need a compilation/build step.npm testruns your Vitest suite and fails the job if a test fails.
Why limit permissions?
yaml
permissions:
contents: readThe job needs to read your source code, but does not need permission to change repository content. This is a safer default for a test-only workflow.
About action versions
The @v7 values in this workflow refer to the major versions of GitHub's official checkout and setup-node actions. Official actions evolve, so check their documentation when updating your pipeline. For a production system, consider pinning actions to reviewed commit SHAs.
11. Run the workflow on GitHub
First, check everything locally:
bash
npm run check
npm testConfirm that the following files exist:
text
deployment-workshop/
├── .github/
│ └── workflows/
│ └── ci.yml
├── site/
│ └── index.html
├── src/
│ ├── app.js
│ └── server.js
├── tests/
│ └── app.test.js
├── package.json
└── package-lock.jsonCommit and push your changes:
bash
git add .github/workflows/ci.yml src/app.js src/server.js tests/app.test.js package.json package-lock.json
git commit -m "Add automated API tests and GitHub Actions CI"
git push origin mainIf your default branch is not named main, update the workflow's branch filters and the push command to match your repository.
In GitHub:
- Open your
deployment-workshoprepository. - Select the Actions tab.
- Open the Node.js CI workflow.
- Select the most recent run and open the Test Express API job.
- Inspect the log for each step and verify that the job passed.
A successful run confirms that GitHub checked the committed version of your project using a clean installation, rather than relying on dependencies already installed on your laptop.
12. Check changes using a pull request
Create a feature branch:
bash
git switch -c feature/more-api-testsAdd this new test to the bottom of tests/app.test.js:
js
describe('Response headers', () => {
it('serves JSON on the root route', async () => {
const response = await request(app).get('/');
expect(response.headers['content-type']).toMatch(/json/);
});
});Run the tests and push the branch:
bash
npm test
git add tests/app.test.js
git commit -m "Test JSON response headers"
git push -u origin feature/more-api-testsOpen a pull request from feature/more-api-tests into main on GitHub. You should see the CI checks appear on the pull request. This lets you review the results before merging.
Optional challenge — Observe a failed pull request check
On the feature branch (not on main), change the new test to expect a header pattern the API does not return:
js
expect(response.headers['content-type']).toMatch(/text\/plain/);Commit and push that test change. The pull request's CI should fail. Open the failed job, find the assertion, restore /json/, and push a fix. The next run should pass.
Do not merge until the checks are passing. If you have already enabled Render's “After CI Checks Pass” setting, it still applies to your linked deployment branch; a pull request on another branch does not deploy that branch to your production service.
Once the pull request passes, merge it on GitHub. Then return your local checkout to main and download the merged changes before starting another exercise:
bash
git switch main
git pull --ff-only origin mainCommit any unfinished feature-branch work before switching. If the pull reports diverging branches, resolve that Git history issue before continuing. Merging on GitHub does not automatically switch or update your local branch.
Your CI checkpoint
- [ ]
.github/workflows/ci.ymlis committed to GitHub. - [ ] GitHub Actions ran the workflow for
main. - [ ] I can locate each step's log and identify a passing check.
- [ ] I opened a pull request and saw its CI status.
- [ ] I can explain why
npm ciusespackage-lock.json.
Part 4 — Connect CI to Your Render Deployment
13. Why passing tests should matter before deployment
Your API from Lesson 1 may still be configured to deploy on every commit to main. If so, Render can start deploying a commit even if GitHub Actions later reports a failing test.
You can change this behavior by making Render wait for CI checks.
text
New commit on main
│
▼
GitHub Actions
Run tests
/ \
Pass Fail
│ │
▼ ▼
Render No automatic
deploy Render deploy14. Wait for CI checks before Render auto-deploys
If you still have the Render web service from Lesson 1:
- Open the Render dashboard.
- Select your API service.
- Open Settings and find the Auto-Deploy setting.
- Select After CI Checks Pass.
- Save your changes.
- Push a passing change to
main(or merge a pull request whose tests passed). - Compare the GitHub Actions result with the deployment activity in Render.
- Verify the deployed
/healthendpoint after deployment finishes.
Render's exact dashboard wording and plan options can change. Its deployment documentation describes the currently supported auto-deploy behavior.
Know what this setting does—and does not do
- On Commit: Render starts an automatic deploy when the linked branch changes; it does not wait for CI results.
- After CI Checks Pass: Render waits for the detected checks to pass before automatically deploying that commit.
- Off: Render does not automatically deploy changes to the linked branch.
This does not mean the GitHub Actions workflow itself deploys the code. Render performs the deployment, and GitHub Actions supplies the checks.
An automated test suite also cannot guarantee that an application has no bugs. You still need to verify the live service after deployment.
15. Validate the full path
Once your pull request has passed CI and has been merged, check the following:
- The
mainbranch contains the expected commit. - GitHub Actions reports a successful run for that commit.
- Render reports a successful deployment of the same commit.
- Your public
/healthroute returns:
json
{"status":"ok"}- The root route still returns the expected welcome message.
You now have a CI pipeline and a deployment platform that can use its results. In a later lesson, you will automate the deployment process more directly and work with Docker-based deployments.
Part 5 — Troubleshooting and Good Practices
16. Common problems
| Problem | What to check |
|---|---|
npm test says a script is missing | Confirm package.json contains "test": "vitest run". |
Cannot find package 'vitest' | Run npm install --save-dev vitest supertest and check package-lock.json. |
| Test run does not finish | Use vitest run rather than watch mode in your CI script. |
| Tests fail when importing the app | Confirm tests import src/app.js, not the file that starts the listener. |
| Test expects the wrong response | Compare the assertion with the actual route response. |
npm ci fails on GitHub | Commit an up-to-date package-lock.json; run npm install locally if it is out of sync. |
| No workflow appears in GitHub | Check .github/workflows/ci.yml, the workflow syntax, and repository Actions settings. |
| Workflow does not run on a branch push | This workflow runs on pushes to main, and on pull requests targeting main. |
| Render deploys even when tests fail | Check whether Auto-Deploy is still set to On Commit. |
17. Good habits to keep
- Run tests locally before pushing, so you catch easy failures early.
- Keep tests deterministic. The same code should not pass or fail depending on unrelated external services.
- Avoid production credentials in tests. Today's test suite does not need any secrets or live database connection.
- Use pull requests for changes you want to review before merging.
- Inspect failed logs rather than repeatedly rerunning a broken workflow without changing anything.
- Keep CI permissions minimal. A test workflow should not need production deployment secrets.
- Verify production after deploying. Passing tests is one piece of evidence, not a substitute for checking the deployed API.
Final Exercise — Prove Your CI Pipeline Works
Complete these tasks using your deployment-workshop project:
- From your updated local
main, create a feature branch withgit switch -c feature/version-endpoint. - Add one new API route, such as
GET /version, that returns JSON describing the current API version. - Write at least two tests for that route: one for its HTTP status and another for its response body.
- Run
npm run checkandnpm testlocally, commit and push the branch, and open a pull request targetingmain. - Confirm that GitHub Actions runs and passes.
- Merge the pull request, then verify that the new route is available on Render if deployment is configured.
Example solution — A /version endpoint
Add this route to src/app.js, before export default app:
js
app.get('/version', (_req, res) => {
res.json({ version: '1.0.0' });
});Add these tests to tests/app.test.js:
js
describe('GET /version', () => {
it('returns HTTP 200', async () => {
const response = await request(app).get('/version');
expect(response.status).toBe(200);
});
it('returns the API version', async () => {
const response = await request(app).get('/version');
expect(response.body).toEqual({ version: '1.0.0' });
});
});Once the checks pass and your changes are deployed, you can visit https://YOUR-SERVICE-NAME.onrender.com/version. Replace YOUR-SERVICE-NAME with your Render service subdomain.
After merging this exercise's pull request, prepare your local checkout for Lesson 3:
bash
git switch main
git pull --ff-only origin mainLater commands such as git push origin main push your local main branch, even if you are currently working on a different branch. Finish and merge feature-branch work, then update local main before following those commands.
Final checkpoint
- [ ] My API routes are testable without starting a server manually.
- [ ] I can run and understand the Vitest test suite.
- [ ] GitHub Actions automatically checks changes to my repository.
- [ ] I understand how to diagnose a failed CI job.
- [ ] I can explain the difference between CI, Continuous Delivery, and Continuous Deployment.
- [ ] I understand how Render can wait for CI checks before deploying.
Knowledge Check
Try answering these questions before revealing the suggested answers.
- What is the main purpose of Continuous Integration?
- What is the difference between Continuous Delivery and Continuous Deployment?
- Why did you move the Express routes into
src/app.js? - Why do you use
vitest runinstead of watch mode in CI? - What is the difference between
npm installandnpm ciin this workflow? - What causes the GitHub Actions workflow to start?
- Where do you find a failed step's output in GitHub?
- Does a passing GitHub Actions workflow automatically deploy your code by itself?
- What Render setting prevents an automatic deploy when a CI check fails?
- Why should you still check
/healthafter deploying a change?
Reveal suggested answers
- To integrate and automatically verify code changes frequently, helping teams catch problems early.
- Continuous Delivery keeps code ready for release, typically with a deliberate release action. Continuous Deployment automatically releases changes after required checks pass.
- So tests can import the Express app without creating a long-running HTTP listener.
vitest runcompletes and returns a result; watch mode remains active waiting for file changes.npm ciperforms a clean installation using the committed lockfile and fails if the lockfile is inconsistent.npm installmay update dependencies and the lockfile.- A push to
mainor a pull request targetingmain. - Open the repository's Actions tab, select the run and job, then inspect the relevant step.
- No. This workflow only installs dependencies, validates syntax, and runs tests. Render is still responsible for deployment.
- After CI Checks Pass.
- A green test run does not guarantee that production configuration, startup, routing, or networking all work as expected.
Summary
In this lesson, you:
- Learned how CI, Continuous Delivery, and Continuous Deployment fit together.
- Made your Express routes easy to test by separating app configuration from server startup.
- Wrote Vitest + Supertest API tests and learned what a failing assertion looks like.
- Created a GitHub Actions workflow that runs validation and tests on commits and pull requests.
- Learned how Render can use CI checks to prevent failed changes from automatically deploying.
The key takeaway: Deployment automation becomes much safer when automated checks verify changes before they reach your users.
Before Lesson 3
- [ ] Keep your
deployment-workshopproject and its GitHub repository. - [ ] Keep the new
src/app.js,tests/app.test.js, and.github/workflows/ci.ymlfiles. - [ ] Make sure the test suite passes locally and on GitHub.
- [ ] Check that your Render API still responds correctly.
Next: Lesson 3 — Containerization with Docker. You will package this same Node.js API into a Docker image and learn to run it in a container.