Skip to main content

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 success
  • signOut — should call auth.signOut()
  • getCurrentUser — should return User or null
  • onAuthStateChange — 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)
ApproachFirebase Emulatorvi.mock('firebase/auth')
WhyFirestore works in Node.jsfirebase/auth is browser-only, crashes in Vitest
Test typeIntegration testUnit 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.
  • signInWithPopup mock must return { user: { ... } } — match exactly what your service destructures.
  • Use a let variable + getter for stateful mocks when the value needs to change between calls.
  • beforeEach must 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; }
}