In this lab, you'll set up a complete testing environment from scratch using Vitest, a modern JavaScript/TypeScript testing framework. By the end of this lab, you'll understand the anatomy of a test, configure a real testing environment, and write your first unit and integration tests.
Time Estimate: 90-120 minutes
Prerequisites: Node.js 20+ installed, VS Code, basic TypeScript familiarity
Important
Windows Users: This lab uses terminal commands written for Unix-based systems (macOS/Linux). If you're on Windows, use PowerShell (not Command Prompt) for the best compatibility. Most commands will work identically. Where commands differ, both versions are provided.
After completing this lab, you will be able to:
- Initialize a Node.js project with TypeScript support
- Install and configure Vitest as your testing framework
- Write tests that follow the Arrange-Act-Assert pattern
- Distinguish between unit tests and integration tests in practice
- Run tests and interpret coverage reports
This lab directly applies concepts from your Week 1 readings:
- "But really, what is a JavaScript test?" β You'll see that tests are just code that throws errors when something goes wrong
- "Write tests. Not too many. Mostly integration." β You'll write both unit and integration tests and see the difference in confidence they provide
- The Testing Trophy β You'll set up static analysis (TypeScript) alongside your test framework
After accepting the GitHub Classroom assignment, you'll have a personal repository. Clone it to your local machine:
git clone git@github.com:ClarkCollege-CSE-SoftwareEngineering/cse325-lab1-vitest-setup-YOURUSERNAME.git
cd cse325-lab1-vitest-setup-YOURUSERNAMENote
Replace YOURUSERNAME with your actual GitHub username. You can copy the exact clone URL from your repository page on GitHub.
Your cloned repository already contains:
README.md-- These lab instructions.gitignore-- Pre-configured to ignorenode_modules/,dist/,coverage/, etc..github/workflows/test.yml-- GitHub Actions workflow for automated testing
Warning
The npm-init command below must be run within the root directory of your project (i.e., cse325-lab1-vitest-setup-YOURUSERNAME directory). These instructions assumed you ran the cd cse325-lab1-vitest-setup-YOURUSERNAME command (shown above), so that your working directory is the root of your project.
npm init -yThis creates a package.json file. Open it and update it to enable ES modules:
{
"name": "cse325-lab1-vitest-setup-YOURUSERNAME",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:coverage": "vitest run --coverage"
},
"keywords": [],
"author": "",
"license": "ISC",
- "type": "commonjs"
+ "type": "module"
}Install TypeScript, Vitest, and related tooling:
npm install -D typescript vitest @vitest/coverage-v8Create a tsconfig.json file:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Create a vitest.config.ts file in your project root:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
exclude: ["node_modules/", "vitest.config.ts"],
},
},
});# Linux/macOS/PowerShell:
mkdir -p src/utils
mkdir -p src/services
# Windows Command Prompt:
mkdir src\utils
mkdir src\servicesYour project structure should now look like this:
cse325-lab1-vitest-setup-YOURUSERNAME/
βββ .github/
β βββ workflows/
β βββ test.yml β (provided in template)
βββ .gitignore β (provided in template)
βββ node_modules/
βββ src/
β βββ utils/
β βββ services/
βββ package.json β (you created this)
βββ README.md β (provided in template)
βββ tsconfig.json β (you created this)
βββ vitest.config.ts β (you created this)
β
Checkpoint: Run npm test β it should start Vitest in watch mode (press q to quit). You'll see "No test files found" which is expected.
Before using any framework, let's understand what Kent C. Dodds means when he says "a test is code that throws an error when the actual result does not match the expected output."
Create src/utils/math.ts:
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}Create src/utils/math.test.ts:
import { describe, it, expect } from "vitest";
import { add, multiply, divide } from "./math";
describe("math utilities", () => {
describe("add", () => {
it("adds two positive numbers", () => {
// Arrange
const a = 2;
const b = 3;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(5);
});
it("adds negative numbers", () => {
expect(add(-1, -1)).toBe(-2);
});
it("adds zero", () => {
expect(add(5, 0)).toBe(5);
});
});
describe("multiply", () => {
it("multiplies two numbers", () => {
expect(multiply(3, 4)).toBe(12);
});
it("returns zero when multiplied by zero", () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe("divide", () => {
it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("throws an error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
});npm testYou should see all tests passing. Notice the structure:
describe()groups related testsit()defines individual test casesexpect()makes assertions
π€ Reflection Question: Look at the add tests. The first test uses explicit Arrange-Act-Assert comments. Why might this pattern be useful, especially for complex tests?
Temporarily break the add function to see what a failing test looks like:
export function add(a: number, b: number): number {
return a - b; // Bug introduced!
}Run the tests again and observe:
- Which tests fail?
- What information does Vitest provide about the failure?
- How does this relate to "code that throws an error when actual doesn't match expected"?
Restore the correct implementation before continuing.
Now let's write tests for more realistic functionality.
Create src/utils/strings.ts:
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function truncate(
text: string,
maxLength: number,
suffix = "..."
): string {
if (text.length <= maxLength) {
return text;
}
return text.slice(0, maxLength - suffix.length) + suffix;
}
export function capitalize(text: string): string {
if (!text) return "";
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
export function countWords(text: string): number {
if (!text.trim()) return 0;
return text.trim().split(/\s+/).length;
}Create src/utils/strings.test.ts:
import { describe, it, expect } from "vitest";
import { slugify, truncate, capitalize, countWords } from "./strings";
describe("string utilities", () => {
describe("slugify", () => {
it("converts a simple string to a slug", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("handles multiple spaces", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("removes special characters", () => {
expect(slugify("Hello, World!")).toBe("hello-world");
});
it("handles leading and trailing spaces", () => {
expect(slugify(" Hello World ")).toBe("hello-world");
});
it("handles already lowercase strings", () => {
expect(slugify("hello world")).toBe("hello-world");
});
// TODO: Add your own test case
});
describe("truncate", () => {
it("returns the original string if shorter than maxLength", () => {
expect(truncate("Hello", 10)).toBe("Hello");
});
it("truncates and adds default suffix", () => {
expect(truncate("Hello World", 8)).toBe("Hello...");
});
it("uses custom suffix", () => {
expect(truncate("Hello World", 9, "β¦")).toBe("Hello Woβ¦");
});
it("handles exact length strings", () => {
expect(truncate("Hello", 5)).toBe("Hello");
});
// TODO: Add your own test case
});
describe("capitalize", () => {
it("capitalizes a lowercase word", () => {
expect(capitalize("hello")).toBe("Hello");
});
it("handles already capitalized words", () => {
expect(capitalize("HELLO")).toBe("Hello");
});
it("returns empty string for empty input", () => {
expect(capitalize("")).toBe("");
});
// TODO: Add your own test case
});
describe("countWords", () => {
it("counts words in a simple sentence", () => {
expect(countWords("Hello world")).toBe(2);
});
it("handles multiple spaces between words", () => {
expect(countWords("Hello world")).toBe(2);
});
it("returns zero for empty string", () => {
expect(countWords("")).toBe(0);
});
it("returns zero for whitespace-only string", () => {
expect(countWords(" ")).toBe(0);
});
// TODO: Add your own test case
});
});npm run test:coverageExamine the coverage report. You should see high coverage for the functions you've tested.
Now let's see the difference between unit tests and integration tests, connecting to the Testing Trophy concept.
Create src/services/content.ts:
import { slugify, truncate, capitalize } from "../utils/strings";
export interface Article {
title: string;
body: string;
author: string;
}
export interface ProcessedArticle {
title: string;
slug: string;
excerpt: string;
author: string;
}
export function processArticle(
article: Article,
excerptLength = 100
): ProcessedArticle {
return {
title: capitalize(article.title),
slug: slugify(article.title),
excerpt: truncate(article.body, excerptLength),
author: capitalize(article.author),
};
}
export function processArticles(
articles: Article[],
excerptLength = 100
): ProcessedArticle[] {
return articles.map((article) => processArticle(article, excerptLength));
}
export function findArticleBySlug(
articles: ProcessedArticle[],
slug: string
): ProcessedArticle | undefined {
return articles.find((article) => article.slug === slug);
}Create src/services/content.test.ts:
import { describe, it, expect } from "vitest";
import {
processArticle,
processArticles,
findArticleBySlug,
Article,
} from "./content";
describe("content service", () => {
// Sample test data
const sampleArticle: Article = {
title: "hello world: my first post",
body: "This is the body of my first blog post. It contains multiple sentences and should be truncated in the excerpt.",
author: "jane doe",
};
describe("processArticle", () => {
it("processes an article with all transformations", () => {
const result = processArticle(sampleArticle, 50);
// This is an INTEGRATION test - it tests multiple units working together
expect(result.title).toBe("Hello world: my first post");
expect(result.slug).toBe("hello-world-my-first-post");
expect(result.excerpt).toBe(
"This is the body of my first blog post. It co..."
);
expect(result.author).toBe("Jane doe");
});
it("uses default excerpt length when not specified", () => {
const result = processArticle(sampleArticle);
expect(result.excerpt.length).toBeLessThanOrEqual(100);
});
});
describe("processArticles", () => {
it("processes multiple articles", () => {
const articles: Article[] = [
sampleArticle,
{
title: "SECOND POST",
body: "Another post body here.",
author: "JOHN SMITH",
},
];
const results = processArticles(articles, 50);
expect(results).toHaveLength(2);
expect(results[0].slug).toBe("hello-world-my-first-post");
expect(results[1].slug).toBe("second-post");
expect(results[1].author).toBe("John smith");
});
it("returns empty array for empty input", () => {
expect(processArticles([])).toEqual([]);
});
});
describe("findArticleBySlug", () => {
it("finds an article by its slug", () => {
const processed = processArticles([
sampleArticle,
{ title: "Another Post", body: "Body", author: "Author" },
]);
const found = findArticleBySlug(processed, "hello-world-my-first-post");
expect(found).toBeDefined();
expect(found?.title).toBe("Hello world: my first post");
});
it("returns undefined when slug not found", () => {
const processed = processArticles([sampleArticle]);
const found = findArticleBySlug(processed, "non-existent-slug");
expect(found).toBeUndefined();
});
});
});π€ Reflection Questions:
-
Looking at
strings.test.tsandcontent.test.ts, which file contains unit tests and which contains integration tests? How can you tell the difference? -
If the
slugifyfunction had a bug, which test files would have failing tests? Why does this happen? -
What additional confidence do the integration tests give you that unit tests alone wouldn't provide?
Submit your completed project as a GitHub repository containing:
cse325-lab1-vitest-setup-YOURUSERNAME/
βββ .github/
β βββ workflows/
β βββ test.yml β (provided)
βββ .gitignore β (provided)
βββ src/
β βββ utils/
β β βββ math.ts
β β βββ math.test.ts
β β βββ strings.ts
β β βββ strings.test.ts
β βββ services/
β βββ content.ts
β βββ content.test.ts
βββ package.json
βββ package-lock.json
βββ tsconfig.json
βββ vitest.config.ts
βββ README.md β (update with your content)
The repository includes a README.md with these lab instructions. You can modify this file with any necessary changes to reflect your work. Please be sure to include all of the following:
- Name - Your name at the top of the
README(since GitHub usernames don't all match student names) - Additional Tests - List the test cases you added (so that it's easy to see which ones are new)
- Reflection Answers - Your answers to the reflection questions (shown with a π€ emoji) can be added in-line next to the questions
- Testing Trophy Connection - A brief paragraph (3-5 sentences) explaining how this lab connects to the Testing Trophy concept from your readings
Your submission must:
- Have all provided tests passing
- Include at least 4 additional test cases you wrote yourself (one per
TODOtag) - Achieve at least 90% code coverage on the utility functions
| Criteria | Points |
|---|---|
| Project setup correct (all config files present and valid) | 15 |
| All provided tests pass | 20 |
| 4+ additional test cases written | 20 |
| Code coverage β₯ 90% on utility functions | 15 |
| README complete with reflection answers | 20 |
| Code quality (clean, well-organized) | 10 |
| Total | 100 |
Note
These additional tests are not evaluated by the GitHub Action scripts and will not be included in grading.
If you finish early, try these extensions:
-
Add ESLint β Set up ESLint with TypeScript support. This adds the "Static" layer of the Testing Trophy.
-
Async Testing β Create a function that simulates an async API call and write tests using
async/await:export async function fetchArticle(id: string): Promise<Article> { // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 100)); return { title: "Fetched Article", body: "Content", author: "API" }; }
-
Parameterized Tests β Use Vitest's
it.eachto write parameterized tests:it.each([ ["Hello World", "hello-world"], ["foo bar baz", "foo-bar-baz"], ["Test 123", "test-123"], ])('slugify("%s") returns "%s"', (input, expected) => { expect(slugify(input)).toBe(expected); });
"Cannot find module" errors:
- Ensure you have
"type": "module"in yourpackage.json - Check that file extensions are correct (
.tsfor TypeScript files)
"No inputs were found in config file" errors:
- Make sure
tsconfig.jsonincludes"include": ["src/**/*"] - Ensure you have at least one Typescirpt file in
src/
Coverage not generating:
- Run
npm run test:coverage(not justnpm test) - Ensure
@vitest/coverage-v8is installed
TypeScript errors:
- Make sure
tsconfig.jsonincludes"types": ["vitest/globals"] - Run
npx tsc --noEmitto check for TypeScript errors
Push your completed project to GitHub and submit the repository URL through Canvas by the due date.
Due: Monday of Week 2 (see Canvas for exact date/time)