Testing — Part 5: Writing Tests for authService
Ticket: SCRUM-74 | Status: Approved ✅
What This Task Was About
Write unit tests for authService — the service that handles Google sign-in, sign-out, getting the current user, and listening to auth state changes.
Functions to test:
signInWithGoogle— should return a User object on successsignOut— should callauth.signOut()getCurrentUser— should return User or nullonAuthStateChange— should call the callback when auth state changes
Why We Mock firebase/auth (Not Use the Emulator)
For wordService (Part 4), we used the Firebase Emulator — Firestore works fine in Node.js. Firebase Auth is different: firebase/auth is designed for browsers, not Node.js — it crashes in the Vitest environment.
So instead of an emulator, we use vi.mock() to replace the entire firebase/auth module with fake functions we control.
| wordService (Part 4) | authService (Part 5) | |
|---|---|---|
| Approach | Firebase Emulator | vi.mock('firebase/auth') |
| Why | Firestore works in Node.js | firebase/auth is browser-only, crashes in Vitest |
| Test type | Integration test | Unit test |
What I Did
Setting up the mock
getAuth() returns a Firebase Auth instance — a large object with many methods. You only need to mock what your code actually calls. Trace your own code to find out what that is: currentUser, signOut(), onAuthStateChanged().
const mockUser = {
uid: 'test-uid-123',
email: 'test@example.com',
displayName: 'Test User',
};
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(() => ({
currentUser: mockUser,
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
})),
GoogleAuthProvider: vi.fn(),
signInWithPopup: vi.fn(),
}));
signInWithPopup must return { user: ... }
authService does: const user = result.user — so the mock must resolve with { user: { uid, displayName, email } }. If signInWithPopup is just vi.fn() with no return value, result.user is undefined and the returned id comes back undefined.
signInWithPopup: vi.fn(() => Promise.resolve({ user: mockUser }))
// same as:
signInWithPopup: vi.fn(async () => ({ user: mockUser }))
Both are equivalent — Promise.resolve(value) and async () => value do the same thing.
Stateful Mocks — Simulating signOut()
Problem: The initial mock had currentUser: mockUser hardcoded. This value is captured once when the mock object is created and never updates — so after signOut() runs, getAuth().currentUser still returns mockUser. The test for getCurrentUser after sign-out would fail.
Fix: Make the mock stateful using a variable and a JavaScript getter:
let mockCurrentUser: typeof mockUser | null = mockUser;
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(() => ({
get currentUser() { return mockCurrentUser; }, // reads variable fresh every time
signOut: vi.fn(async () => { mockCurrentUser = null; }), // mutates it
onAuthStateChanged: vi.fn((cb) => { cb(); return vi.fn(); }),
})),
GoogleAuthProvider: vi.fn(),
signInWithPopup: vi.fn(async () => ({ user: mockUser })),
}));
beforeEach(() => {
mockCurrentUser = mockUser; // reset before every test
});
Why get currentUser() works: A regular property (currentUser: mockCurrentUser) copies the value at the moment the object is created. A getter (get currentUser() { return mockCurrentUser; }) runs every time the property is accessed — so it always returns the current value of the variable.
Both are accessed the same way (auth.currentUser, no parentheses) but behave differently:
// Regular property — value frozen at creation time
const auth = { currentUser: mockCurrentUser };
mockCurrentUser = null;
auth.currentUser; // still mockUser ← bug
// Getter — reads variable fresh each time
const auth = { get currentUser() { return mockCurrentUser; } };
mockCurrentUser = null;
auth.currentUser; // null ← correct
Important: Why the Getter Isn't Always Needed
In this test, getAuth() is called fresh inside every service function (signInWithGoogle, signOut, getCurrentUser). Each call creates a brand new mock object with currentUser: mockCurrentUser evaluated at that moment.
So after signOut() sets mockCurrentUser = null, the next call to getAuth() inside getCurrentUser() creates a new object that picks up null correctly — no getter strictly needed in this case.
In production, Firebase creates the auth object once and reuses it everywhere — so the getter becomes important there to reflect the latest currentUser state on the same object.
Object Literals: Properties, Methods, and Getters
JavaScript lets you mix all three in one {} block:
const auth = {
currentUser: mockCurrentUser, // property — copies value at creation
signOut: async () => { ... }, // method — a function
get currentUser() { return mockCurrentUser; }, // getter — runs on every access
};
Getters look like class syntax — because it's the same concept:
// Class version
class Auth {
get currentUser() { return mockCurrentUser; }
}
// Object literal — same concept, no class needed
const auth = {
get currentUser() { return mockCurrentUser; }
};
Testing onAuthStateChange
onAuthStateChange registers a callback with auth.onAuthStateChanged. Since onAuthStateChanged is a vi.fn(), you capture and call the callback manually in the test:
it('should call the callback with the current user', () => {
const callback = vi.fn();
onAuthStateChange(callback);
expect(callback).toHaveBeenCalled();
});
The mock is set up as onAuthStateChanged: vi.fn((cb) => { cb(); return vi.fn(); }) — it immediately calls cb() and returns a mock unsubscribe function.
Mistakes and Fixes
1. currentUser not stateful (hardcoded value)
See the stateful mock section above. Fix: use a let variable + getter.
2. Missing beforeEach reset for mockCurrentUser
After the signOut test sets mockCurrentUser = null, subsequent tests start with null instead of mockUser. Always reset in beforeEach.
3. clearFirestore() in beforeEach — unnecessary
authService tests don't touch Firestore at all (everything is mocked). Importing and calling clearFirestore() adds noise without purpose.
4. Missing onAuthStateChange test
The ticket required all 4 functions to be tested. Easy to miss since it's the least obvious to test — but the pattern is simple once you know onAuthStateChanged is a vi.fn() you can call manually.
Key Takeaways
- Mock what your code uses, not the entire Firebase SDK. Trace your own code to find the surface area.
signInWithPopupmock must return{ user: { ... } }— match exactly what your service destructures.- Use a
letvariable + getter for stateful mocks when the value needs to change between calls. beforeEachmust reset any stateful mock variables so tests don't bleed into each other.firebase/auth= mock it.firebase/firestore= emulator works fine.
Session Notes & Code Review Feedback
Katie Nguyen (2026-04-05):
[Learning Notes] SCRUM-74 — Writing tests for authService
Why we mock firebase/auth (not use the emulator): firebase/auth is designed for browsers, not Node.js — it crashes in the Vitest environment. wordService/categoryService used the Firestore emulator because Firestore works fine in Node.js. For auth, vi.mock() replaces the entire firebase/auth module with fake functions you control.
What getAuth() returns — you only need to mock what your code actually calls: currentUser, signOut(), onAuthStateChanged(). You don't need to know everything the real object contains — just trace your own code.
Promise.resolve() vs async/await in mocks — both are equivalent. signInWithPopup must return { user: ... } — authService does const user = result.user. If signInWithPopup is just vi.fn() with no return value, result.user is undefined.
Stateful mocks: getAuth() mock had currentUser: mockUser hardcoded — it never changed, so signOut() had no effect in tests. Fix: make the mock stateful using a variable and a getter.
Without get currentUser(), the value is captured once at mock creation and never updated. With get currentUser(), it reads mockCurrentUser fresh every time the property is accessed. Reset mockCurrentUser = mockUser in beforeEach so each test starts with a logged-in user.
Katie Nguyen (2026-04-05):
[Code Review - Changes Needed] SCRUM-74 — Round 1
What went well: correct vi.mock() and vi.fn() — no Jest syntax. mockUser defined cleanly. Stateful mock implemented correctly. signInWithPopup correctly returns { user: { uid, displayName, email } }. Tests check actual values (toBe('test-uid-123')) not just truthiness.
Changes needed:
- Missing test for onAuthStateChange — all 4 functions required
- Missing beforeEach reset for mockCurrentUser — after signOut test sets it to null, subsequent tests start wrong
- clearFirestore() in beforeEach is unnecessary — authService tests don't touch Firestore
How to test onAuthStateChange:
it('should call the callback with the current user', () => {
const callback = vi.fn();
onAuthStateChange(callback);
expect(callback).toHaveBeenCalled();
});
Katie Nguyen (2026-04-05):
[Code Review - Changes Needed] SCRUM-74 — Round 2
What went well: all 4 functions now tested. clearFirestore removed. beforeEach resets mockCurrentUser. onAuthStateChange test is simple and correct.
One change still needed: Line 10 currentUser: mockCurrentUser is still hardcoded — the value is captured once when the mock is created. When signOut() sets mockCurrentUser = null, getAuth().currentUser still returns the original mockUser.
Fix: use a JS getter:
getAuth: vi.fn(() => ({
get currentUser() { return mockCurrentUser; },
signOut: vi.fn(async () => { mockCurrentUser = null; }),
onAuthStateChanged: vi.fn((cb) => { cb(); return vi.fn(); }),
}))
Why: currentUser: mockCurrentUser copies the value at creation. get currentUser() is a function that runs every time you access the property, so it always returns whatever mockCurrentUser is right now.
Katie Nguyen (2026-04-05):
[Learning Notes] Object literals, getters, and why the getter isn't always needed
In this test, getAuth() is called fresh inside every service function — each call creates a brand new object with currentUser: mockCurrentUser evaluated at that moment. So after signOut() sets mockCurrentUser = null, the next getAuth() call creates a new object picking up null correctly — no getter strictly needed.
In production, Firebase creates the auth object once and reuses it everywhere — so a getter is important to reflect the latest currentUser state on the same object.
Getter looks like a class — because it is the same concept:
// Class version
class Auth {
get currentUser() { return mockCurrentUser; }
}
// Object literal version — same concept, no class needed
const auth = {
get currentUser() { return mockCurrentUser; }
}