Skip to main content

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=shared passes

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:

MocksFirebase Emulator
What runsFake functions returning hardcoded valuesReal Firebase SDK talking to a local fake server
What it testsThat you called the right functionsThat your actual read/write logic is correct
ValueUnit test — verifies code structureIntegration test — verifies real behaviour
Setupvi.mock(addDoc).mockResolvedValue(...) per functionconnectFirestoreEmulator() 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: {}):

LocationControlsExamples
Top levelHow 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':

  1. createWord receives it as the word parameter — wordService.ts:51
  2. Line 54: getWordsByUser(word.userId) — queries Firestore for words where userId == '1'
  3. Line 63: wordConverter.toFirestore(word) spreads ...word — wordService.ts:18, so userId: '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​

HookRunsUse for
beforeAllOnce before the entire suiteExpensive setup done once (start a server)
beforeEachBefore every it blockResetting state between tests (clear DB)
afterEachAfter every it blockCleanup 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

  1. "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.
  2. 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
  3. 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.
  4. 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.
  5. 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:

  1. Removed auth.currentUser from createWord — always null in tests. Use word.userId instead.
  2. filter vs find bug in duplicate check — filter always returns an array (truthy), find returns undefined for no match.
  3. Missing () on toBeDefined — always passes silently. Must be toBeDefined().
  4. The ! non-null assertion — use only after asserting not.toBeNull() first.
  5. beforeAll vs beforeEach vs afterEach — beforeEach clears DB before every test for proper isolation.

Katie Nguyen (2026-03-05):

Steps taken:

  1. No mock needed — using Firebase emulator.
  2. Bug with Firebase auth: missing envDir in config. Added root vitest.config.ts.
  3. Test word has userId (for getWordsByUser) and name. No id field — Firestore assigns it.
  4. beforeEach calls clearFirestore() defined in FirebaseConfig.ts.
  5. Each test follows Arrange-Act-Assert.
  6. Command: npm test --workspace=shared

Katie Nguyen (2026-03-05):

Additional Lessons:

  1. Object spread order — { id: docRef.id, ...word } lets word.id overwrite. Fix: { ...word, id: docRef.id }. Last key wins.
  2. 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.