Test a Password Validator with Vitest
Write unit tests in TypeScript that catch a real bug in a password validator.
Introduction
30 Second Summary
Every time you sign up for a new account, the site judges your password. Too short, no special characters, already appeared in a data breach. Behind those warnings is code that someone had to get right.
In this project, you will build a TypeScript password strength validator and write a full test suite for it using Vitest. You'll catch a real bug through testing, mock an external dependency, and run parameterized tests across dozens of inputs.
What You'll Build
You'll run npx vitest in your terminal and watch a wall of green checkmarks prove that your password validator handles everything from empty strings to breached passwords.
By the end of this project, you'll have:
- A full green test suite you can run with a single command to prove your password validator scores passwords correctly, catches edge cases, and flags breached passwords.
- Parameterized tests and watch mode that validate dozens of password combinations in one block and re-run instantly as you edit code.
- A mocked async dependency that simulates an external breach-checking API, keeping your tests fast and completely offline.
- Secret Mission: Add code coverage reporting and push your test suite toward 100% coverage.
Are there any prerequisites?
You should be comfortable with basic TypeScript concepts like types, interfaces, functions, and async/await. No prior testing experience is needed.
Before We Start
You're about to build a TypeScript password strength validator and write a full test suite for it using Vitest. Before any hands-on work, let's lock in what you're building and why testing matters for a project like this.
Set Up Your Vitest Project
Before you can write a single test, you need a project with the right tools in place. Vitest needs a TypeScript project with ES modules configured, or your test files won't run.
In this step, you'll create a fresh project folder, install Vitest and TypeScript, configure the project for ESM, and prove everything works by running a trivial passing test.
In this step, get ready to:
- Create a project folder and install Vitest and TypeScript.
- Configure your project for ES modules and strict TypeScript.
- Write and run a trivial test to confirm everything works.
Create your project folder and install dependencies
Every project starts with a dedicated folder and a package.json that tracks your dependencies. You'll create both from the terminal.
- Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir password-validator
cd password-validator
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON block printed in the terminal showing your new package.json contents.
- Install Vitest and TypeScript as development dependencies by running:
npm install -D vitest typescript
You should see output confirming packages were added, ending with a line like added X packages.
Seeing permission errors or command not found?
If npm is not recognized, Node.js is not installed or not in your PATH. Revisit the Node.js installation and make sure you restarted your terminal after installing.
On macOS, if you see EACCES permission errors, do NOT use sudo. Instead, reinstall Node.js using the official installer from nodejs.org.
help me fix npm install errors
- Open the password-validator folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the password-validator folder on your Desktop, then click Open.
You should see node_modules, package.json, and package-lock.json listed in the VS Code Explorer sidebar.
Configure your project files
The default package.json from npm init is missing a few things: it defaults to CommonJS instead of ES modules, and has no test script. You also need a TypeScript config so the compiler knows how to handle your code.
- In VS Code, click on package.json in the Explorer sidebar to open it.
- Select all the content and replace it with the following:
{
"name": "password-validator",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "^4.1.10",
"typescript": "^7.0.2"
}
}
What does this configuration do?
- "type": "module" tells Node.js to use ES module syntax (import/export) instead of CommonJS (require).
- The test script starts Vitest in watch mode. The test:run script runs tests once and exits.
- The devDependencies list matches what npm install -D added. Your version numbers may differ slightly and that is fine.
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Create a new file by right-clicking in the Explorer sidebar and selecting New File.
- Name it tsconfig.json and press Enter.
- Paste the following TypeScript configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["src", "tests"]
}
What does this configuration do?
- "module": "ESNext" and "moduleResolution": "bundler" align TypeScript with ES module resolution, matching your package.json setting.
- "strict": true enables all strict type-checking options so TypeScript catches more bugs at compile time.
- "include" tells TypeScript which folders contain source code. Everything else is ignored.
- Save tsconfig.json.
- Confirm your files exist by switching to the terminal and running:
ls
You should see node_modules, package-lock.json, package.json, and tsconfig.json in the output.
Write and run your first test
Vitest needs its own config file to know it's a Vitest project. You also need at least one source file and one test file to prove the whole pipeline works.
- Back in VS Code, create a new file in the project root called vitest.config.ts by right-clicking in the Explorer sidebar and selecting New File.
- Paste the following Vitest configuration:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {},
})
What does this file do?
This imports defineConfig from vitest/config and exports an empty test configuration. Vitest looks for this file automatically. You'll add options here later if needed, but an empty test: {} is enough to get started.
- Save vitest.config.ts.
- Create the source file by right-clicking in the Explorer sidebar, selecting New File, and typing src/sum.ts.
VS Code automatically creates the src folder when you include the slash in the filename.
- Paste the following trivial function into src/sum.ts:
export function sum(a: number, b: number): number {
return a + b
}
- Save src/sum.ts.
You should see a src folder with sum.ts inside it in your Explorer sidebar.
- Create the test file by right-clicking in the Explorer sidebar, selecting New File, and typing tests/sum.test.ts.
- Paste the following test code:
import { expect, test } from 'vitest'
import { sum } from '../src/sum.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
What does this test do?
- import { expect, test } from 'vitest' brings in the two APIs you need: test defines a test case and expect makes assertions.
- The import path uses ../src/sum.js with a .js extension because Node.js ESM resolution requires file extensions. Vitest maps .js back to the .ts source automatically.
- expect(sum(1, 2)).toBe(3) calls your function and asserts the result equals 3. If it doesn't, the test fails.
- Save tests/sum.test.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before running the test.
ⓧ I'd like to double check the full code
Here is src/sum.ts:
export function sum(a: number, b: number): number {
return a + b
}
Here is tests/sum.test.ts:
import { expect, test } from 'vitest'
import { sum } from '../src/sum.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
Before you run the test, what do you think will happen? Will Vitest find your test file and pass, or will something go wrong?
- Run your first test by switching to the terminal and running:
npx vitest run
You should see a green checkmark with 1 passed in the output. Your test ran, called sum(1, 2), and confirmed the result equals 3.
Tests not running or failing?
- If you see a module resolution error, make sure "type": "module" is in your package.json and your import path in the test uses ../src/sum.js (with the .js extension).
- If Vitest says it found no test files, check that your file is named sum.test.ts (not sum.tests.ts or sumTest.ts). Vitest looks for files containing .test. or .spec. in the filename.
- If you see a TypeScript syntax error, make sure tsconfig.json exists and has "strict": true inside compilerOptions.
help me debug why my Vitest test is not running or failing
Your testing environment is ready with a green checkmark to prove it. Next up, you'll replace this trivial sum function with a real password validator and write tests that actually exercise complex logic.
Build the Validator and Write Your First Real Tests
The sum test proved your setup works, but a one-line addition function isn't going to teach you about real-world testing. You need code with actual logic, branching, and edge cases to see what testing looks like in practice.
In this step, you'll build a password strength validator that scores passwords on length and character variety. Then you'll write tests that prove it works from multiple angles and experience Vitest's watch mode in action.
In this step, get ready to:
- Build a validatePassword function that returns a score, strength label, and list of issues.
- Write three tests covering weak, strong, and missing-category scenarios.
- Experience watch mode by breaking a test and seeing Vitest instantly catch it.
Create the password validator
Your validator will check a password against several criteria: minimum length, uppercase letters, lowercase letters, numbers, and special characters. Each check adds a point to the score, and the final score maps to a strength label.
- In VS Code, create a new file called src/validator.ts inside the existing src folder.
- Add the interface and the first part of the function by pasting this code:
export interface ValidationResult {
score: number
strength: 'weak' | 'fair' | 'strong'
issues: string[]
}
export function validatePassword(password: string): ValidationResult {
const issues: string[] = []
let score = 0
if (password.length >= 8) {
score += 1
} else {
issues.push('Password must be at least 8 characters')
}
if (password.length >= 12) {
score += 1
}
What does this code do?
- ValidationResult defines the shape of what the function returns: a numeric score, a strength label, and an array of issues found.
- The function starts with 0 points and an empty issues list, then awards 1 point for meeting the 8-character minimum and a bonus point for reaching 12 characters.
- Directly below the length checks, add the character-variety checks:
if (/[A-Z]/.test(password)) {
score += 1
} else {
issues.push('Password must contain an uppercase letter')
}
if (/[a-z]/.test(password)) {
score += 1
} else {
issues.push('Password must contain a lowercase letter')
}
if (/[0-9]/.test(password)) {
score += 1
} else {
issues.push('Password must contain a number')
}
if (/[^A-Za-z0-9]/.test(password)) {
score += 1
} else {
issues.push('Password must contain a special character')
}
What do these regex checks do?
Each regular expression tests for one character category. If the password passes, it earns a point. If it fails, the function pushes a human-readable issue into the array so the caller knows what's missing.
The pattern /[^A-Za-z0-9]/ matches anything that is NOT a letter or digit, which catches special characters like !, @, or #.
- Finally, add the strength calculation and closing brace to complete the function:
let strength: 'weak' | 'fair' | 'strong'
if (score <= 2) {
strength = 'weak'
} else if (score <= 4) {
strength = 'fair'
} else {
strength = 'strong'
}
if (/(.)1{2,}/.test(password)) {
score -= 1
issues.push('Password contains repeated characters')
}
return { score, strength, issues }
}
What does this section do?
The score maps to a strength label: 0-2 is 'weak', 3-4 is 'fair', and 5+ is 'strong'.
The repeated-character regex /(.)\1{2,}/ catches any character repeated 3+ times in a row (like aaa) and penalizes the score by 1 point.
- Save src/validator.ts.
- Confirm VS Code shows no red underlines in the file. The TypeScript language server validates your types automatically.
Seeing red underlines?
- Make sure the interface and function are both inside the same file, with the interface above the function.
- Check that you haven't accidentally left the function body open. The closing } at the very end should close the validatePassword function.
- help me fix TypeScript errors in my validator file
Write your first real tests
Your validator exists, but you have no proof it works correctly. Tests give you that proof by calling the function with specific inputs and asserting the outputs match your expectations.
- Create a new file called tests/validator.test.ts inside the existing tests folder.
- Add the following test suite:
import { describe, it, expect } from 'vitest'
import { validatePassword } from '../src/validator.js'
describe('validatePassword', () => {
it('returns weak for an empty string', () => {
const result = validatePassword('')
expect(result.strength).toBe('weak')
expect(result.score).toBe(0)
})
it('returns strong for a complex password', () => {
const result = validatePassword('MyP@ssw0rd!2024')
expect(result.strength).toBe('strong')
expect(result.issues).toHaveLength(0)
})
it('identifies missing uppercase letters', () => {
const result = validatePassword('lowercase1!')
expect(result.issues).toContain('Password must contain an uppercase letter')
})
})
What does this test file do?
- describe groups related tests under a shared label. When Vitest runs, it shows this label as a heading in the output.
- it defines a single test case. Each test calls validatePassword with a specific input and checks the result.
- expect(...).toBe(...) asserts an exact match. toContain checks that an array includes a specific string. toHaveLength checks the array's size.
- The import path uses .js because Node.js ESM resolution requires file extensions. TypeScript resolves .js imports to the corresponding .ts file automatically.
- Save tests/validator.test.ts.
Before you run the tests, predict: do you think all three will pass on the first try?
- Run the test suite by entering this command in your terminal:
npx vitest run
You should see green checkmarks next to all three tests, plus the sum test from earlier. The output shows 4 tests passing across 2 test files.
Tests failing?
- If you see "Cannot find module", check that the import path uses ../src/validator.js (with the .js extension). Node.js ESM requires file extensions even for TypeScript files.
- If a test expects a specific string but gets a different one, double-check that the issue strings in validator.ts match what the test asserts exactly. Capitalization matters.
- help me debug my failing validator tests
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Your src/validator.ts file should contain, from top to bottom:
- The ValidationResult interface (score, strength, issues).
- The validatePassword function opening with issues and score initialization.
- Two length checks (>= 8 and >= 12).
- Four character-variety checks (uppercase, lowercase, numbers, special).
- The strength assignment (weak/fair/strong based on score).
- The repeated-character penalty.
- The return statement.
Your tests/validator.test.ts should be:
import { describe, it, expect } from 'vitest'
import { validatePassword } from '../src/validator.js'
describe('validatePassword', () => {
it('returns weak for an empty string', () => {
const result = validatePassword('')
expect(result.strength).toBe('weak')
expect(result.score).toBe(0)
})
it('returns strong for a complex password', () => {
const result = validatePassword('MyP@ssw0rd!2024')
expect(result.strength).toBe('strong')
expect(result.issues).toHaveLength(0)
})
it('identifies missing uppercase letters', () => {
const result = validatePassword('lowercase1!')
expect(result.issues).toContain('Password must contain an uppercase letter')
})
})
Experience watch mode
Running npx vitest run executes tests once and exits. Vitest also has a watch mode that stays running and re-executes tests the instant you save a file.
- Start watch mode by running:
npx vitest
You should see all 4 tests pass, and the terminal stays open waiting for file changes.
- Switch back to tests/validator.test.ts in VS Code.
- In the first test, change expect(result.strength).toBe('weak') to expect(result.strength).toBe('strong').
- Save the file.
Watch your terminal. Vitest instantly re-runs and shows a red failure for that test, with a clear diff showing it expected 'strong' but received 'weak'.
- Change the assertion back to expect(result.strength).toBe('weak') and save.
- Confirm all tests go green again in the terminal.
Why is watch mode useful?
Watch mode gives you a live feedback loop. Every time you save a file, Vitest re-runs only the affected tests in milliseconds. You never have to switch to the terminal and manually type a run command.
Leave watch mode running for the rest of this project. It catches mistakes the moment you make them.
Watch mode not detecting changes?
- Make sure you saved the file (Cmd+S on macOS, Ctrl+S on Windows). Vitest reacts to file saves, not keystrokes.
- If the terminal says "No test files found", check that your file is named validator.test.ts (with .test. in the name).
- help me fix Vitest watch mode
You now have real tests proving your validator works. Next up, you'll write edge-case tests that reveal a hidden bug in your code.
Write Edge Case Tests That Reveal a Bug
In the previous step you wrote three tests and they all pass. That feels reassuring. But passing tests only prove the cases you checked.
Your validatePassword function has a subtle bug hiding in plain sight. The strength label is calculated BEFORE the repeated-character penalty is applied. A password like Aaaa1234! gets rated strong when it should be fair. In this step, you will write a test that exposes this bug, fix it, and add more edge case tests to prove the fix holds.
In this step, get ready to:
- Write a test that catches a real bug in your validator.
- Fix the bug and watch the test go green.
- Add more edge case tests to strengthen your suite.
Write a test that exposes the repeated-character bug
A password like Aaaa1234! has uppercase, lowercase, numbers, and a special character. But the repeated aaa pattern should reduce the score enough to knock it down from strong to fair. Let's write a test that asserts this.
- In tests/validator.test.ts, find the closing }) of the describe('validatePassword') block.
- Add this test just above that closing line:
it('penalizes repeated characters in strength rating', () => {
const result = validatePassword('Aaaa1234!')
expect(result.strength).toBe('fair')
})
What does this test do?
The password Aaaa1234! meets every character category (uppercase, lowercase, number, special). That gives it a high raw score. But the three repeated a characters should trigger a penalty that drops the strength to fair.
Before you save, what do you think will happen when Vitest re-runs? Will this new test pass or fail?
- Save tests/validator.test.ts.
- Switch to the terminal where Vitest watch mode is running.
The test fails. You expected fair but received strong. This is the bug in action. The code assigns the strength label based on the score, then applies the repeated-character penalty afterwards. The penalty lowers the score too late to affect the label.
Why did the test fail?
Look at the bottom of src/validator.ts. The strength calculation (the if (score <= 2) block) runs first. Then the repeated-character check subtracts from the score. By that point the strength variable is already set and never updated.
Fix the bug in your validator
The fix is straightforward: move the repeated-character check above the strength calculation so the penalty is applied before the label is decided.
- In src/validator.ts, find this block near the bottom of the function:
let strength: 'weak' | 'fair' | 'strong'
if (score <= 2) {
strength = 'weak'
} else if (score <= 4) {
strength = 'fair'
} else {
strength = 'strong'
}
if (/(.)\ 1{2,}/.test(password)) {
score -= 1
issues.push('Password contains repeated characters')
}
return { score, strength, issues }
- Swap the order so the repeated-character check comes first, then the strength calculation. The end of your function should now look like this:
if (/(.)\ 1{2,}/.test(password)) {
score -= 1
issues.push('Password contains repeated characters')
}
let strength: 'weak' | 'fair' | 'strong'
if (score <= 2) {
strength = 'weak'
} else if (score <= 4) {
strength = 'fair'
} else {
strength = 'strong'
}
return { score, strength, issues }
- Save src/validator.ts and switch to your terminal.
Vitest re-runs automatically. Your repeated-character test now passes with a green checkmark. The penalty is applied before the label, so Aaaa1234! correctly gets rated fair.
Test still failing?
- Make sure you removed the repeated-character block from its old position (below the strength calculation). Having it in both places will cause a duplicate variable error.
- Check that the strength calculation block is still intact. The three conditions and the variable declaration must remain unchanged.
Help me debug my validator fix.
Add more edge case tests
One edge case test caught a real bug. More edge cases will strengthen your confidence in the validator. You will add two tests: one for a password with only special characters and one that checks the length bonus.
- In tests/validator.test.ts, add this test below the repeated-character test you just wrote:
it('handles passwords with only special characters', () => {
const result = validatePassword('!@#$%^&*()')
expect(result.issues).toContain(
'Password must contain an uppercase letter'
)
expect(result.issues).toContain(
'Password must contain a lowercase letter'
)
expect(result.issues).toContain('Password must contain a number')
})
What does this test check?
A password made entirely of special characters satisfies the length and special-character rules but misses uppercase, lowercase, and numbers. This test confirms the validator correctly identifies all three missing categories at once.
- Add one more test directly below, still inside the same describe block:
it('gives bonus score for length >= 12', () => {
const short = validatePassword('Short1!a')
const long = validatePassword('LongerPass1!abc')
expect(long.score).toBeGreaterThan(short.score)
})
What does this test check?
Both passwords satisfy all character categories. The only difference is length. The 12+ character password earns a bonus point, so its score should be strictly higher. This confirms the length bonus logic works.
- Save tests/validator.test.ts.
Vitest re-runs in your terminal. Both new tests pass immediately. Your suite now has 6 passing tests inside the validatePassword describe block.
Seeing a failure on the new tests?
- Check that Short1!a is exactly 8 characters. If it's shorter than 8, the length rule fails and the test logic breaks.
- Make sure both new tests are inside the closing parenthesis of the describe block, not outside it.
Help me fix my edge case tests.
Before you confirm everything is green, predict: how many total tests do you expect to pass when you run npx vitest run? Remember you also have the sum.test.ts file from earlier.
- Confirm all tests pass by running:
npx vitest run
You should see 7 passing tests total: 6 inside your validatePassword describe block and 1 from sum.test.ts. All green.
✔️ Awesome, I've got everything!
Great. Double check you have saved both tests/validator.test.ts and src/validator.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain each of these pieces, in order.
For tests/validator.test.ts:
- The import line and describe block opening from Step 2.
- The three original tests (empty string, complex password, missing uppercase).
- The repeated-character test you added in substep 1 of this step.
- The only-special-characters test from substep 3.
- The length bonus test from substep 3.
- The closing of the describe block.
For src/validator.ts:
- All six character-category checks are unchanged.
- The repeated-character check (if (/(.)\ 1{2,}/.test(password))) sits ABOVE the strength calculation.
- The strength calculation and return statement are the last things in the function.
You just experienced something developers rely on every day. A test caught a real bug that your other tests missed. Next up, you will organize your growing test suite into logical groups and run parameterized tests across many inputs at once.
Organize Tests with describe, beforeEach, and test.each
Your test suite has six passing tests, which is great. But they're all sitting in one flat list inside a single describe block.
As test files grow, flat lists become hard to scan. Real test suites group related tests under nested headings, and avoid copy-pasting the same test logic for different inputs. In this step, you'll restructure your test file using nested describe blocks and a powerful pattern called test.each.
In this step, get ready to:
- Reorganize tests into nested describe blocks with grouped terminal output.
- Add parameterized tests with test.each to validate 5 passwords in a single block.
Reorganize tests into nested describe blocks
Nesting describe blocks inside another describe creates a hierarchy. Vitest shows this hierarchy in the terminal output, making it easy to see which group a failing test belongs to.
You'll split your six tests into two groups: "basic validation" for happy-path tests, and "edge cases" for the tests that caught the bug.
- In tests/validator.test.ts, replace the entire file content with this first section containing the updated import and the basic validation group:
import { describe, it, expect, test } from 'vitest'
import { validatePassword } from '../src/validator.js'
describe('validatePassword', () => {
describe('basic validation', () => {
it('returns weak for an empty string', () => {
const result = validatePassword('')
expect(result.strength).toBe('weak')
expect(result.score).toBe(0)
})
it('returns strong for a complex password', () => {
const result = validatePassword('MyP@ssw0rd!2024')
expect(result.strength).toBe('strong')
expect(result.issues).toHaveLength(0)
})
it('identifies missing uppercase letters', () => {
const result = validatePassword('lowercase1!')
expect(result.issues).toContain('Password must contain an uppercase letter')
})
})
What changed here?
Two things changed from your previous file.
- The import now includes test alongside describe, it, and expect. You'll need test for test.each later.
- The first three tests (empty string, complex password, missing uppercase) are now wrapped in describe('basic validation', () => { ... }). The tests themselves are identical to before.
- Directly below the closing }) of the basic validation block, add the edge cases group:
describe('edge cases', () => {
it('penalizes repeated characters in strength rating', () => {
const result = validatePassword('Aaaa1234!')
expect(result.strength).toBe('fair')
})
it('handles passwords with only special characters', () => {
const result = validatePassword('!@#$%^&*()')
expect(result.issues).toContain('Password must contain an uppercase letter')
expect(result.issues).toContain('Password must contain a lowercase letter')
expect(result.issues).toContain('Password must contain a number')
})
it('gives bonus score for length >= 12', () => {
const short = validatePassword('Short1!a')
const long = validatePassword('LongerPass1!abc')
expect(long.score).toBeGreaterThan(short.score)
})
})
})
What does this code do?
The edge cases group holds the three tests that probe unusual inputs: the repeated-character bug you fixed, all-special-character passwords, and the length bonus. The final }) closes the outer describe('validatePassword') block.
- Save tests/validator.test.ts.
- Run your tests to see the new grouped output:
npx vitest run
The terminal now shows your tests under nested headings like validatePassword > basic validation and validatePassword > edge cases. Each test appears indented under its group.
Seeing a syntax error?
- Count your closing brackets. You should have }) at the end of each nested describe, plus one final }) closing the outer describe('validatePassword').
- Make sure the import line includes test (you'll need it for the next chunk).
Help me fix a syntax error in my nested describe blocks.
Add parameterized tests with test.each
Your tests currently check one password per test function. If you wanted to test 10 different passwords, you'd write 10 nearly identical tests. test.each solves this by running the same test logic across an array of inputs, generating a separate named test for each row.
- In tests/validator.test.ts, find the closing }) of the edge cases describe block.
- Add this new parameterized tests block between that closing and the final }) of the outer describe:
describe('parameterized tests', () => {
test.each([
{ password: 'ab', expectedStrength: 'weak' },
{ password: 'abcdefgh', expectedStrength: 'weak' },
{ password: 'Abcdefg1', expectedStrength: 'fair' },
{ password: 'Abcdefg1!', expectedStrength: 'strong' },
{ password: 'MyStr0ng!Pass', expectedStrength: 'strong' },
])(
'password "$password" should be $expectedStrength',
({ password, expectedStrength }) => {
const result = validatePassword(password)
expect(result.strength).toBe(expectedStrength)
}
)
})
What does this code do?
- test.each([...]) takes an array of objects. Each object becomes the input for one test run.
- The template string 'password "$password" should be $expectedStrength' generates a human-readable name for each test using the row's values.
- The callback ({ password, expectedStrength }) => { ... } destructures each row object and runs the same assertion logic with different data.
- Five rows means five separate tests in the output, each with its own pass/fail status.
- Save tests/validator.test.ts.
Before you run this, how many total tests do you think the output will show? Count the original 6 plus the 5 parameterized rows.
- Run the full test suite to see your final result:
npx vitest run
You should see all tests pass with grouped headings. The parameterized block shows 5 separate test names like password "ab" should be weak and password "MyStr0ng!Pass" should be strong.
Seeing test failures in the parameterized block?
- Check that you typed the passwords exactly as shown. A missing character changes the score calculation.
- Make sure the parameterized tests block sits inside the outer describe('validatePassword') block, not after it.
Help me debug failing test.each tests in Vitest.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Your tests/validator.test.ts file should contain these sections in order. Scroll up and confirm each one is present:
- Import line with describe, it, expect, test from vitest.
- Import of validatePassword from ../src/validator.js.
- Outer describe('validatePassword') wrapping everything.
- Nested describe('basic validation') with 3 tests (empty string, complex password, missing uppercase).
- Nested describe('edge cases') with 3 tests (repeated characters, only special characters, length bonus).
- Nested describe('parameterized tests') with a test.each block containing 5 password rows.
Your tests are now organized into logical groups and your terminal output reads like a table of contents. Next up, you'll add a breach-checking module and learn how to mock an async dependency so your tests stay fast and offline.
Mock a Dependency with vi.mock
Your validator only checks character patterns. Real-world apps also check whether a password has appeared in known data breaches by calling an external API.
Without mocking, tests that call an external service are slow (waiting on the network) and flaky (dependent on the service being up). Mocking lets you replace a dependency with a fake version you control, so your tests stay fast and deterministic.
In this step, get ready to:
- Create a breach-checker module that simulates an async API call.
- Mock the breach-checker in your tests so they run instantly without network calls.
- Write tests that verify both the breached and safe outcomes.
Create the breach-checker module
You need a module that simulates checking a password against a breach database. In production this would be a real API call, but for learning purposes you'll use a hardcoded list with a delay.
- Create a new file src/breach-checker.ts and add the following code:
export async function checkBreachedPassword(password: string): Promise<boolean> {
// Simulated list of known breached passwords
const breachedPasswords = ['password123', '12345678', 'qwerty123']
// Simulate network delay (100ms)
await new Promise(resolve => setTimeout(resolve, 100))
return breachedPasswords.includes(password)
}
What does this code do?
This function pretends to be an external API. It checks a password against a short hardcoded list and returns true if the password has been "breached."
The 100ms setTimeout simulates network latency. This is exactly the kind of delay you want to eliminate from your tests with mocking.
Now you need a function in your validator that uses this breach checker. You'll add validatePasswordFull to src/validator.ts.
- In src/validator.ts, add this import at the very top of the file (above the export interface line):
import { checkBreachedPassword } from './breach-checker.js'
- At the bottom of src/validator.ts (after the closing } of validatePassword), add this new function:
export async function validatePasswordFull(
password: string
): Promise<ValidationResult & { breached: boolean }> {
const result = validatePassword(password)
const breached = await checkBreachedPassword(password)
if (breached) {
result.issues.push('Password has appeared in a data breach')
result.score = Math.max(0, result.score - 2)
if (result.score <= 2) {
result.strength = 'weak'
} else if (result.score <= 4) {
result.strength = 'fair'
}
}
return { ...result, breached }
}
What does this function do?
- It runs the existing validatePassword first, getting the base score and issues.
- Then it calls the breach checker. If the password is breached, it subtracts 2 from the score and may downgrade the strength rating.
- The return type ValidationResult & { breached: boolean } extends the original result with a breached flag.
- Save both src/breach-checker.ts and src/validator.ts.
- Confirm your existing tests still pass by running:
npx vitest run
- You should see all your previous tests still passing. The new code doesn't break anything because no test calls validatePasswordFull yet.
Seeing import errors?
- Make sure the import path uses .js extension: './breach-checker.js'. Node.js ESM requires file extensions even for TypeScript files.
- Verify the import line is at the very top of src/validator.ts, before the interface declaration.
Why is my breach-checker import failing?
Mock the dependency and write your first mock tests
If you tested validatePasswordFull without mocking, each test would wait 100ms for the simulated network call. Multiply that by dozens of tests and your suite gets painfully slow. Mocking replaces the real function with a fake you control.
- In tests/validator.test.ts, find the import line at the top of the file:
import { describe, it, expect, test } from 'vitest'
import { validatePassword } from '../src/validator.js'
- Replace those two lines with the following updated imports, the new breach-checker import, and the vi.mock call:
import { describe, it, expect, vi, beforeEach, test } from 'vitest'
import { validatePassword, validatePasswordFull } from '../src/validator.js'
import { checkBreachedPassword } from '../src/breach-checker.js'
vi.mock('../src/breach-checker.js', () => ({
checkBreachedPassword: vi.fn(),
}))
What does vi.mock do?
vi.mock intercepts every import of '../src/breach-checker.js' and replaces it with the factory function you provide. Instead of the real async function, your tests get a vi.fn() mock that does nothing by default.
Vitest automatically hoists this call to the top of the file, so the mock is in place before any imports are resolved. You don't need to worry about import order.
- At the bottom of tests/validator.test.ts (after the closing }) of the describe('validatePassword') block), add this new describe block:
describe('validatePasswordFull', () => {
it('flags a breached password', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(true)
const result = await validatePasswordFull('password123')
expect(result.breached).toBe(true)
expect(result.issues).toContain('Password has appeared in a data breach')
})
it('does not flag a safe password', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(false)
const result = await validatePasswordFull('MyStr0ng!Pass')
expect(result.breached).toBe(false)
expect(result.issues).not.toContain('Password has appeared in a data breach')
})
})
What's happening here?
- vi.mocked(checkBreachedPassword) is a type helper that tells TypeScript the function is a mock. It gives you access to mock methods like .mockResolvedValue().
- .mockResolvedValue(true) makes the mock return a resolved Promise with true. No 100ms delay, no hardcoded list. You decide the outcome.
- The first test simulates a breached password. The second simulates a safe one. Both complete instantly because the mock skips the real delay.
- Save tests/validator.test.ts.
- Run your tests to confirm the mocked tests pass:
npx vitest run
- You should see the two new tests under validatePasswordFull passing alongside all your earlier tests. Notice how fast they complete. No 100ms wait per test.
Tests failing with undefined?
- Make sure the vi.mock path matches your import path exactly: '../src/breach-checker.js'.
- Check that you exported validatePasswordFull from src/validator.ts and imported it in your test file.
Why are my vi.mock tests returning undefined?
Add test isolation and verify mock arguments
Your two tests work, but there's a hidden risk. Each test sets .mockResolvedValue() on the same mock. If one test's setup leaks into another, you get confusing false passes. A beforeEach block resets the mock before every test, guaranteeing isolation.
- In tests/validator.test.ts, find the opening of your describe('validatePasswordFull') block. Add a beforeEach and two more tests so the block looks like this:
describe('validatePasswordFull', () => {
beforeEach(() => {
vi.mocked(checkBreachedPassword).mockReset()
})
it('flags a breached password', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(true)
const result = await validatePasswordFull('password123')
expect(result.breached).toBe(true)
expect(result.issues).toContain('Password has appeared in a data breach')
})
it('does not flag a safe password', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(false)
const result = await validatePasswordFull('MyStr0ng!Pass')
expect(result.breached).toBe(false)
expect(result.issues).not.toContain('Password has appeared in a data breach')
})
What does mockReset do?
mockReset() clears the mock's call history AND resets its implementation. This means each test starts with a clean slate. Without it, a stale .mockResolvedValue(true) from a previous test could leak through and give false results.
- Now add these two tests after the "does not flag a safe password" test, still inside the same describe block:
it('reduces score when password is breached', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(true)
const result = await validatePasswordFull('MyStr0ng!Pass')
expect(result.score).toBeLessThan(6)
expect(result.breached).toBe(true)
})
it('calls checkBreachedPassword with the correct argument', async () => {
vi.mocked(checkBreachedPassword).mockResolvedValue(false)
await validatePasswordFull('TestPass1!')
expect(checkBreachedPassword).toHaveBeenCalledWith('TestPass1!')
})
What do these tests prove?
- The "reduces score" test verifies that a breached password loses points. A strong password that's breached should no longer have the maximum score.
- The .toHaveBeenCalledWith('TestPass1!') test verifies that your function actually passes the password to the breach checker. This catches wiring bugs where you accidentally hardcode a value or pass the wrong variable.
- Make sure the describe('validatePasswordFull') block has a closing }) after the last test.
- Save tests/validator.test.ts.
Before you run the tests, think about what you expect to see. How many total tests should be passing now?
- Run the full suite:
npx vitest run
You should see all tests passing, including 4 tests under the validatePasswordFull group. The mock tests complete instantly with no 100ms delay per call.
Seeing 'not a function' or 'mockReset is not a function'?
- Make sure you wrap the function in vi.mocked() before calling .mockReset() or .mockResolvedValue(). Calling these directly on the import won't work without the type wrapper.
- Verify that vi and beforeEach are both included in your import from 'vitest'.
Why is vi.mocked not working with mockReset?
Why mocking matters beyond speed
Mocking isn't just about performance. It gives you complete control over the dependency's behavior. You can simulate errors, edge cases, and specific return values that would be difficult or impossible to trigger with the real implementation.
The .toHaveBeenCalledWith() assertion is especially powerful. It proves your code is wired correctly without relying on the dependency's internal logic.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all three files: src/breach-checker.ts, src/validator.ts, and tests/validator.test.ts.
ⓧ I'd like to double check the full code
Here's the complete src/breach-checker.ts:
export async function checkBreachedPassword(password: string): Promise<boolean> {
const breachedPasswords = ['password123', '12345678', 'qwerty123']
await new Promise(resolve => setTimeout(resolve, 100))
return breachedPasswords.includes(password)
}
For src/validator.ts and tests/validator.test.ts, scroll up and confirm each chunk is present in order:
- src/validator.ts: the import { checkBreachedPassword } line at the top, the existing validatePassword function unchanged, and the new validatePasswordFull function at the bottom.
- tests/validator.test.ts: the updated imports (including vi, beforeEach), the vi.mock call, the existing describe('validatePassword') block unchanged, and the new describe('validatePasswordFull') block with beforeEach and 4 tests.
You've now mocked an async dependency, tested both outcomes, and verified the mock was called with the right arguments. Your test suite covers character validation, edge cases, parameterized inputs, and external dependencies. Next up: the Secret Mission challenges you to measure exactly how much of your code these tests actually cover.
Secret mission
Add Code Coverage and Push Toward 100%
Your tests all pass, but how much of your code do they actually exercise? In this secret mission, you'll install a coverage provider, run a coverage report, and hunt down the untested branch hiding in your validator.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project folder for future experimentation or delete it to free up disk space.
Resources you used:
- The password-validator project folder on your Desktop (includes node_modules, source files, and test files).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your test suite, adding more utility modules, or practicing Vitest patterns.
- Your project folder is ready to extend with new modules and tests at any time.
- Run npx vitest to re-enter watch mode whenever you want to resume.
Pause - I'll come back to this later
There are no running processes or cloud resources to pause. Your project folder stays on disk until you decide to delete it.
- If Vitest is still running in watch mode, press Ctrl+C in the terminal to stop it.
- Everything else is already at rest. Come back any time and pick up where you left off.
Delete - I don't want to use this again
Remove all project files from your machine. No cloud resources to clean up.
- If Vitest is still running in watch mode, press Ctrl+C in the terminal to stop it.
- Delete the project folder by running the appropriate command for your operating system.
On macOS or Linux:
cd ~/Desktop
rm -rf password-validator
On Windows:
cd ~\Desktop
rmdir /s /q password-validator
That's everything. No accounts, services, or global packages need separate cleanup.
Nice Work!
Nice Work!
You just built a fully-tested TypeScript password strength validator with Vitest. Your test suite catches real bugs, mocks async dependencies, and runs parameterized checks across dozens of inputs.
You've learned how to:
- Write unit tests with Vitest and discovered a real bug through testing. The repeated-character penalty was applied after the strength calculation, and your test caught it.
- Organize a growing test suite with describe, beforeEach, and test.each to keep tests readable and avoid duplicating logic across similar cases.
- Mock an async dependency with vi.mock and vi.mocked so your tests stay fast, deterministic, and fully offline.
- Secret Mission: Added code coverage reporting to find untested branches and pushed your validator toward 100% coverage.
Ready to quiz yourself?