Skip to content

refactor(tests): shift service tests to contract-testing pattern (Issue #238)#242

Merged
smithrashell merged 1 commit intomainfrom
refactor/contract-testing-238
Dec 21, 2025
Merged

refactor(tests): shift service tests to contract-testing pattern (Issue #238)#242
smithrashell merged 1 commit intomainfrom
refactor/contract-testing-238

Conversation

@smithrashell
Copy link
Owner

Summary

Shifts service tests from implementation-testing (verifying mock calls) to contract-testing (verifying return shapes). This makes tests more resilient to refactoring while still catching real bugs.

Changes Made

sessionService.test.js (+76/-55 lines)

  • Added assertCheckAndCompleteContract() helper to verify return contracts:
    • 'not_found' → returns false
    • 'completed' → returns []
    • 'unattempted' → returns Array<SessionProblem> with required fields
  • Added assertNewSessionContract() helper for Session type verification
  • Removed mock call verifications like expect(getSessionById).toHaveBeenCalledWith()
  • All tests now focus on what the function RETURNS, not HOW it works internally

problemService.test.js (+95/-52 lines)

  • Added assertProblemLookupContract() helper with JSDoc explaining the found flag semantic
  • Added assertMergedProblemContract() for merged problem verification
  • Added assertAddOrUpdateContract() for add/update response verification
  • Removed assertProblemServiceCall() (implementation testing)
  • Removed assertAttemptsServiceCall() (implementation testing)
  • Renamed unused createAttemptData to _createAttemptData

Before/After Example

Before (Implementation Testing):

const result = await SessionService.checkAndCompleteSession(sessionId);
expect(getSessionById).toHaveBeenCalledWith(sessionId);  // ❌ Implementation detail
expect(updateSessionInDB).toHaveBeenCalled();            // ❌ Implementation detail
expect(result).toEqual([]);

After (Contract Testing):

const result = await SessionService.checkAndCompleteSession(sessionId);
assertCheckAndCompleteContract(result, 'completed');     // ✅ Verifies return contract

Files Not Changed

  • dashboardService.test.js - Already uses contract-testing pattern
  • sessionService.critical.test.js - Already has good contract helpers

Test Plan

  • Run npm run lint - no errors
  • Run npm test - 696 tests passing (10 skipped)
  • All 42 test suites pass

Closes #238

@claude
Copy link

claude bot commented Dec 21, 2025

PR Review: Contract-Testing Refactor

Overview

This PR successfully shifts service tests from implementation-testing to contract-testing, addressing Issue #238. The refactor improves test resilience to refactoring while maintaining the ability to catch real bugs. All 696 tests pass.


✅ Strengths

1. Excellent Contract Design

The contract assertion helpers are well-designed and clearly document expected return shapes:

// problemService.test.js:117
const assertProblemLookupContract = (result, expectedFound, expectedProblem = undefined) => {
  expect(result).toHaveProperty('problem');
  expect(result).toHaveProperty('found');
  expect(result.found).toBe(expectedFound);
  // ...
}

Why this is good:

  • The JSDoc explaining the found flag semantic (lines 106-116) is excellent
  • Functions are flexible with optional parameters for different test scenarios
  • Clear separation of concerns (lookup vs merged vs add/update)

2. Consistent Comment Pattern

Each test includes a // CONTRACT: comment describing expected behavior:

// sessionService.test.js:133
// CONTRACT: Returns [] when all problems are attempted (session just completed)

Impact: Makes tests self-documenting and clarifies intent for future maintainers.

3. Proper Cleanup

Good removal of implementation-testing artifacts:

  • Removed assertProblemServiceCall() and assertAttemptsServiceCall() helpers
  • Properly renamed unused helper to _createAttemptData (line 96)
  • Removed mock call verifications throughout

4. Maintains Test Coverage

The refactor preserves all test scenarios while improving their robustness:

  • Session completion/incompletion states
  • Edge cases (not found, empty arrays, null values)
  • Error scenarios in algorithm failure tests

🔍 Code Quality Observations

Minor: Inconsistent Verification Depth

sessionService.test.js:213-219

expect(result).not.toBeNull();
expect(result.id).toBe("resume-session-123");
expect(result.status).toBe("in_progress");
expect(result.problems).toEqual([1, 2]);
expect(result.attempts).toHaveLength(1);
expect(result).toHaveProperty('currentProblemIndex');

Issue: This test verifies specific values ("resume-session-123", [1, 2]) rather than just the contract shape.

Suggestion: Consider if this is testing implementation details. The contract should be "returns session with these fields" not "returns this exact session." However, if this is validating data integrity (input → output preservation), it's fine.

Alternative approach:

assertNewSessionContract(result); // Verify shape
expect(result.id).toBe(mockSession.id); // Verify data preservation
expect(result.problems).toEqual(mockSession.problems);

Minor: Redundant Assertions

problemService.test.js:274-276

expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);

Issue: toHaveLength(0) already implies array type. The first assertion is redundant.

Suggestion:

expect(result).toEqual([]); // Simpler and equally clear

Documentation: Missing Contract Specification

sessionService.test.js:92

const assertCheckAndCompleteContract = (result, expectedState, expectedUnattemptedCount = 0)

Observation: This helper has inline docs in comments but lacks JSDoc like assertProblemLookupContract has.

Suggestion: Add JSDoc for consistency:

/**
 * Contract assertion: Verify checkAndCompleteSession return matches expected contract.
 * 
 * @param {boolean|Array} result - The function return value
 * @param {'not_found'|'completed'|'unattempted'} expectedState - Expected state
 * @param {number} [expectedUnattemptedCount] - For 'unattempted' state, expected count
 */

Good news: This is already present in the code! No action needed.


🎯 Best Practices Adherence

✅ Follows Project Standards

  • Naming conventions: Helper functions use camelCase with descriptive names
  • Test organization: Clear separation into test groups via runXTests() pattern
  • Mock management: Proper beforeEach() cleanup with mockClear()

✅ Contract-Testing Principles

Successfully implements the target pattern from Issue #238:

  • Tests verify return shapes, not internal calls
  • Refactoring internals won't break tests
  • Still catches real bugs (e.g., wrong return type, missing fields)

🚀 Performance Considerations

Positive Impact

  • Removed unnecessary mock verifications: Tests run slightly faster without checking every mock call
  • Simpler assertions: Direct shape validation is computationally cheaper than multiple toHaveBeenCalledWith() checks

No Concerns

  • Test execution time should remain similar or improve
  • No evidence of inefficient patterns introduced

🔒 Security Considerations

No security concerns. This is purely a test refactor with no changes to production code or security-sensitive test scenarios.


🧪 Test Coverage Assessment

Coverage Maintained ✅

The refactor preserves all existing test scenarios:

sessionService.test.js:

  • ✅ Completed sessions → empty array
  • ✅ Incomplete sessions → unattempted problems
  • ✅ Session not found → false
  • ✅ Already completed → idempotent behavior
  • ✅ Resume scenarios
  • ✅ Skip problem functionality

problemService.test.js:

  • ✅ Found in both stores (merged data)
  • ✅ Found only in standard problems
  • ✅ Not found anywhere
  • ✅ Add/update operations
  • ✅ Algorithm failure edge cases (500+ lines preserved!)

Excellent: Algorithm Failure Tests Retained

Lines 423-746 contain comprehensive edge case testing:

  • Division by zero in success rate calculations
  • Floating point precision errors
  • Corrupted data handling
  • Database integrity failures

These are already contract-style tests and weren't touched by this refactor—smart decision!


📊 Suggested Improvements (Optional)

1. Extract Magic Strings to Constants

Current (problemService.test.js:153-159):

const assertAddOrUpdateContract = (result, expectAttemptAdded) => {
  if (expectAttemptAdded) {
    expect(result).toHaveProperty('message');
    expect(result.message).toContain('Attempt');
  } else {
    expect(result).toHaveProperty('success');
  }
};

Suggestion: Consider using snapshot testing or more specific contracts:

const ATTEMPT_ADDED_CONTRACT = {
  message: expect.stringContaining('Attempt'),
  sessionId: expect.any(String)
};

const PROBLEM_ADDED_CONTRACT = {
  success: expect.any(Boolean)
};

const assertAddOrUpdateContract = (result, expectAttemptAdded) => {
  const expected = expectAttemptAdded ? ATTEMPT_ADDED_CONTRACT : PROBLEM_ADDED_CONTRACT;
  expect(result).toMatchObject(expected);
};

Benefit: More explicit contracts, easier to extend.

2. Consider Parameterized Tests for Similar Scenarios

Current: Multiple similar tests for assertCheckAndCompleteContract:

it("should return empty array when all problems are attempted", async () => { ... });
it("should return empty array for already completed session", async () => { ... });

Suggestion: Could use test.each() for DRYer code:

test.each([
  ['all problems attempted', mockSessionComplete, 'completed'],
  ['already completed', mockSessionAlreadyComplete, 'completed'],
  ['not found', null, 'not_found'],
])('checkAndCompleteSession: %s returns expected contract', async (_, mockSession, expectedState) => {
  // setup and assertions
});

Trade-off: Less readable but more maintainable. Only consider if you add many more similar cases.


🐛 Potential Bugs (None Found)

No bugs identified. The refactor is careful and well-executed.


✨ Final Recommendation

APPROVE ✅

This PR successfully achieves its goal of shifting to contract-testing while maintaining test coverage and quality. The implementation is clean, well-documented, and follows project conventions.

Merge Checklist (Confirmed)

Optional Follow-ups (Not Blocking)

  1. Consider adding JSDoc to any remaining contract helpers missing it (if any)
  2. Evaluate if parameterized tests would benefit heavily duplicated test patterns
  3. Continue this pattern for remaining service test files mentioned in Issue refactor: Shift tests from implementation-testing to contract-testing #238 (attemptsService, storageService)

💡 Key Takeaway

This refactor exemplifies the difference between testing implementation vs testing behavior. The new tests answer: "Does this function return what callers need?" rather than "Did this function call these other functions?"

Impact: Future refactoring of service internals (like changing from calculateTagMastery to a different calculation method) won't break these tests, but changing the return shape will—exactly what we want!

Great work! 🎉

@smithrashell smithrashell merged commit 7e9ef12 into main Dec 21, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: Shift tests from implementation-testing to contract-testing

1 participant