Testing — Part 4: Writing Tests for wordService
Ticket: SCRUM-72 | Status: Approved ✅
What This Task Was About
With Vitest and React Testing Library set up (Parts 2 and 3), this ticket was about actually writing the tests. The target was wordService — the service that handles all Firestore CRUD operations for vocabulary words.
Acceptance criteria:
- Test file at
shared/src/services/__tests__/wordService.test.ts - All 5 CRUD functions have at least one test:
createWord,getWordsByUser,getWord,updateWord,deleteWord npm test --workspace=sharedpasses
Key decision: Use the Firebase Emulator instead of mocking — write integration tests, not pure unit tests.
Mocking vs Using the Firebase Emulator
The original ticket description suggested mocking Firebase:
vi.mock('firebase/firestore');
vi.mock('../../firebase/config');
But since the Firebase Emulator was already set up (Part 3 / SCRUM-127), the better approach was to use it instead. Here's why:
| Mocks | Firebase Emulator | |
|---|---|---|
| What runs | Fake functions returning hardcoded values | Real Firebase SDK talking to a local fake server |
| What it tests | That you called the right functions | That your actual read/write logic is correct |
| Value | Unit test — verifies code structure | Integration test — verifies real behaviour |
| Setup | vi.mock(addDoc).mockResolvedValue(...) per function | connectFirestoreEmulator() once in setup.ts |
Using the emulator means fewer mocks to write and more confidence that the service actually works end-to-end.
What I Did
The only setup needed per test: a test word object with a userId field (used for getWordsByUser) and a name. I deliberately didn't include an id — createWord ignores it and Firestore assigns its own.
const testWord = {
userId: 'user-123',
name: 'ephemeral',
definition: 'lasting for a very short time',
example: 'The cherry blossoms are ephemeral.',
};
Before each test, clear the emulator database so tests don't share leftover data:
beforeEach(async () => {
await clearFirestore();
});
Each test follows Arrange → Act → Assert:
describe('wordService', () => {
describe('createWord', () => {
it('should create a word and return it with a Firestore-generated id', async () => {
// Arrange
const word = { ...testWord };
// Act
const result = await createWord(word);
// Assert
expect(result).not.toBeNull();
expect(result!.id).toBeDefined();
expect(result!.name).toBe('ephemeral');
});
});
describe('getWordsByUser', () => {
it('should return words belonging to the user', async () => {
await createWord(testWord);
const words = await getWordsByUser('user-123');
expect(Array.isArray(words)).toBe(true);
expect(words[0].name).toBe('ephemeral');
});
});
describe('getWord', () => {
it('should return a word when it exists', async () => {
const created = await createWord(testWord);
const result = await getWord(created!.id);
expect(result).not.toBeNull();
expect(result!.name).toBe('ephemeral');
});
it('should return null when word does not exist', async () => {
const result = await getWord('nonexistent-id');
expect(result).toBeNull();
});
});
});
Command to run tests:
npm test --workspace=shared
Setup Issues Encountered
1. "describe is not defined" error
shared/vitest.config.ts had globals: true, but there was no root-level vitest.config.ts. When running npm test from the root, Vitest uses its own defaults (globals: false) and ignores the package-level config. Dependencies are only installed at the root level.
Fix: Create a vitest.config.ts at the project root with globals: true. Each package can still have its own config for package-specific settings like setupFiles.
2. getAuth failing in Node.js test environment
The setup.ts file was calling connectAuthEmulator, which triggered getAuth. This caused an error because the .env file wasn't found by Vitest — it lives at the project root, but Vitest was looking in shared/ by default. All VITE_FIREBASE_* variables were undefined, so Firebase couldn't initialise.
Fix: Add envDir: '../' to the top level of shared/vitest.config.ts:
export default defineConfig({
envDir: '../', // ← top level (Vite option, not inside test:{})
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/services/__tests__/setup.ts'],
},
});
3. Top-level vs inside test: {} in vitest config
defineConfig combines Vite config (top level) and Vitest config (inside test: {}):
| Location | Controls | Examples |
|---|---|---|
| Top level | How files are loaded and processed (Vite) | envDir, resolve, plugins |
Inside test: {} | How the test suite runs (Vitest) | globals, environment, setupFiles |
envDir must go at the top level because .env loading is a Vite responsibility, not a Vitest one.
Bugs Found and Fixed in wordService Itself
1. auth.currentUser → word.userId
createWord was using auth.currentUser to identify the user before saving. In the test environment there's no logged-in user, so auth.currentUser was always null.
Fix: Use word.userId instead — the caller already passes it as part of the word object. Tests can control userId directly without needing an authenticated user.
How do I know it will use that hardcoded userId?
Trace through the call when your test runs createWord(testWord) where testWord.userId = '1':
createWordreceives it as thewordparameter —wordService.ts:51- Line 54:
getWordsByUser(word.userId)— queries Firestore for words whereuserId == '1' - Line 63:
wordConverter.toFirestore(word)spreads...word—wordService.ts:18, souserId: '1'gets written to Firestore
The word object you pass in is the source of truth. createWord never looks up auth.currentUser — it only reads from whatever you passed in. So your test is 100% in control of the userId.
2. filter vs find in the duplicate check
The duplicate check used words.filter() inside an if block. This was always truthy because filter always returns an array — even an empty array is truthy in JavaScript. So createWord always thought a duplicate existed and always returned null.
// Bug — always truthy
if (words.filter(w => w.name === word.name)) { return null; }
// Fix — returns undefined when no match
if (words.find(w => w.name === word.name)) { return null; }
3. Object spread order — last key wins
// Bug — word.id overwrites docRef.id
return { id: docRef.id, ...word };
// Fix — docRef.id always wins
return { ...word, id: docRef.id };
In a spread, the last definition of a key survives. The original code spread word after id: docRef.id, so if the test word had an id: 1 field, it overwrote the real Firestore-generated ID. Then getWord(created.id) searched for id: 1 which didn't exist in Firestore and returned null.
4. Missing await on async function
// Bug — words holds a Promise, not the array
let words = getWordsByUser(userId);
Array.isArray(words); // false — it's a Promise object
// Fix
let words = await getWordsByUser(userId);
Array.isArray(words); // true
A function that returns Promise<Word[]> only gives you Word[] after the await.
5. Missing () on .toBeDefined — silent passing test
// Bug — references the function but never calls it, always "passes"
expect(result.id).toBeDefined
// Fix
expect(result.id).toBeDefined()
One of the most insidious testing mistakes — the test always passes regardless of the actual value, giving completely false confidence.
Key Concepts
beforeAll vs beforeEach vs afterEach
| Hook | Runs | Use for |
|---|---|---|
beforeAll | Once before the entire suite | Expensive setup done once (start a server) |
beforeEach | Before every it block | Resetting state between tests (clear DB) |
afterEach | After every it block | Cleanup after each test |
For test isolation, beforeEach is almost always the right choice — each test gets a fully clean database. With only beforeAll, tests in the same run share leftover data and can affect each other.
The ! non-null assertion operator
When a value can be null (like Word | null), TypeScript won't let you access properties on it directly:
const result = await getWord(id); // type: Word | null
result.name; // TS error: Object is possibly 'null'
result!.name; // OK — tells TS to trust it's not null
Rule: Only use ! after asserting expect(result).not.toBeNull() first. Otherwise you're hiding a TypeScript warning without verifying anything — a null value will still throw a runtime error.
Session Notes & Code Review Feedback
Katie Nguyen (2026-03-03):
Test Setup Discussion — Vitest + Firebase Emulator
- "describe is not defined" error — shared/vitest.config.ts had globals: true but no root-level config. Dependencies are at root. Fix: created root vitest.config.ts with globals: true.
- Right approach: keep vitest deps at root, create root vitest.config.ts, each package has its own for package-specific settings. Run with:
npm test --workspace=shared - vi.mock() replaces all Firestore functions with fakes (unit test). Since we have the emulator, we don't need mocks — we write integration tests against the real local emulator. More valuable.
- getAuth failing — setup.ts called connectAuthEmulator which triggered getAuth. .env file not found by vitest because it was looking in shared/ not root. Fix: added
envDir: '../'to top level of shared/vitest.config.ts. - defineConfig top-level = Vite options (envDir, resolve, plugins). Inside test: = Vitest options (globals, environment, setupFiles). envDir must be top-level.
Katie Nguyen (2026-03-03):
Follow-up Discussion:
- Removed auth.currentUser from createWord — always null in tests. Use word.userId instead.
- filter vs find bug in duplicate check — filter always returns an array (truthy), find returns undefined for no match.
- Missing () on toBeDefined — always passes silently. Must be toBeDefined().
- The ! non-null assertion — use only after asserting not.toBeNull() first.
- beforeAll vs beforeEach vs afterEach — beforeEach clears DB before every test for proper isolation.
Katie Nguyen (2026-03-05):
Steps taken:
- No mock needed — using Firebase emulator.
- Bug with Firebase auth: missing envDir in config. Added root vitest.config.ts.
- Test word has userId (for getWordsByUser) and name. No id field — Firestore assigns it.
- beforeEach calls clearFirestore() defined in FirebaseConfig.ts.
- Each test follows Arrange-Act-Assert.
- Command:
npm test --workspace=shared
Katie Nguyen (2026-03-05):
Additional Lessons:
- Object spread order —
{ id: docRef.id, ...word }lets word.id overwrite. Fix:{ ...word, id: docRef.id }. Last key wins. - await —
let words = getWordsByUser(userId)without await gives a Promise, not the array. Must await async functions.
Katie Nguyen (2026-03-05):
[Code Review - APPROVED]
Good work completing all 5 CRUD tests. Using the Firebase emulator instead of mocks is the right call and produces more valuable tests.
What went well:
- All 5 functions tested: createWord, getWordsByUser, getWord, updateWord, deleteWord
- beforeEach(clearFirestore) gives every test a clean database — correct approach for integration tests
- Arrange-Act-Assert comments used consistently
- getWord has two cases: word exists AND word does not exist — good edge case thinking
- deleteWord creates a word with a different name (temporary) to avoid the duplicate check blocking the test — smart workaround
Things to improve for next time:
- createWord first test uses result!.id but never asserts expect(result).not.toBeNull() first. Always add the null check before using ! — otherwise a null return throws a runtime error instead of a clean test failure message.
- testWord fixture has id: 1 which is unused (createWord ignores it, Firestore assigns its own id). Cleaner to omit the id field entirely to avoid confusion.
- getWordsByUser test only checks Array.isArray — does not verify the array has the right content. A stronger test would create a word first, then assert the array includes that word.
- The top of the file still has commented-out mock code. These can be deleted since the emulator approach is confirmed.