Part of the CrisisCore Build Log - quality assurance for systems that need to work when humans can't
How do you write a test for "user is having a breakdown"?
How do you assert that your UI correctly detected cognitive fog, when cognitive fog isn't a Jest matcher?
This is the testing challenge I face with Pain Tracker. The features that matter mostβcrisis detection, stress-adaptive UI, trauma-informed responsesβare triggered by human states that can't be directly simulated in code.
Here's how I approach it.
The Problem: States vs. Signals
Unit tests verify code behavior. But trauma-informed features respond to human behavior patterns.
You can't write this test:
// β This doesn't exist
test('should activate emergency mode when user has cognitive fog', () => {
const user = simulateCognitiveFog(); // π€·ββοΈ
expect(screen.getByRole('emergency-ui')).toBeInTheDocument();
});
What you can test is the signal processing pipeline:
// β
Test the signals, not the human state
test('should activate emergency mode when error rate exceeds threshold', () => {
// Simulate the *signals* of cognitive fog, not the fog itself
for (let i = 0; i < 10; i++) {
crisisDetection.trackError();
}
crisisDetection.trackHelpRequest();
crisisDetection.trackHelpRequest();
expect(crisisDetection.crisisLevel).toBe('moderate');
});
The insight: you're not testing human experienceβyou're testing your interpretation of observable signals.
Strategy 1: Fixture-Based State Injection
Instead of simulating real-time behavior, inject known states through test fixtures:
// Test wrapper with configurable preferences
interface TestWrapperProps {
children: ReactNode;
preferences?: Partial<TraumaInformedPreferences>;
updatePreferences?: (updates: Partial<TraumaInformedPreferences>) => void;
}
function TestWrapper({
children,
preferences = {},
updatePreferences = vi.fn(),
}: TestWrapperProps) {
const value = {
preferences: { ...defaultPreferences, ...preferences },
updatePreferences,
};
return (
<TraumaInformedContext.Provider value={value}>
{children}
</TraumaInformedContext.Provider>
);
}
Now you can test any combination of preferences:
describe('useCrisisSupport', () => {
it('should adjust thresholds for high sensitivity', () => {
render(
<TestWrapper preferences={{ crisisDetectionSensitivity: 'high' }}>
<CrisisSupportConsumer />
</TestWrapper>
);
expect(screen.getByTestId('pain-threshold').textContent).toBe('7');
});
it('should adjust thresholds for low sensitivity', () => {
render(
<TestWrapper preferences={{ crisisDetectionSensitivity: 'low' }}>
<CrisisSupportConsumer />
</TestWrapper>
);
expect(screen.getByTestId('pain-threshold').textContent).toBe('9');
});
});
The principle: Don't simulate fog. Inject the preference state that fog would trigger.
Strategy 2: Behavioral Signal Generators
For the crisis detection system, I built test utilities that generate the signals a distressed user might produce:
// Sample mood entries covering crisis states
export const sampleMoodEntries: MoodEntry[] = [
{
id: 1,
timestamp: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
mood: 3,
energy: 2,
anxiety: 8,
stress: 9,
hopefulness: 4,
selfEfficacy: 3,
context: 'Severe pain flare-up, emergency room visit',
triggers: ['acute pain', 'medical emergency', 'work absence'],
copingStrategies: ['breathing exercises', 'pain medication'],
notes: 'Overwhelmed by sudden pain onset. Anxious about work and recovery.',
},
// ... entries showing gradual improvement or deterioration
];
And for pain patterns:
/**
* Generate a series of pain entries showing various patterns
*/
export function generatePainSeries(
startDate: Date,
days: number,
pattern: 'improving' | 'worsening' | 'fluctuating' | 'stable' = 'improving'
): PainEntry[] {
const entries: PainEntry[] = [];
for (let i = 0; i < days; i++) {
let intensity: number;
switch (pattern) {
case 'improving':
intensity = Math.max(1, Math.min(10, 8 - (i / days) * 6));
break;
case 'worsening':
intensity = Math.max(1, Math.min(10, 2 + (i / days) * 6));
break;
case 'fluctuating':
intensity = Math.max(1, Math.min(10, 5 + Math.sin(i / 2) * 3));
break;
case 'stable':
default:
intensity = 5;
}
entries.push(makePainEntry({
timestamp: new Date(startDate.getTime() - (days - i - 1) * 86400000).toISOString(),
intensity: Math.round(intensity),
}));
}
return entries;
}
Now you can test how your analytics respond to realistic data patterns:
test('should detect worsening trend', () => {
const entries = generatePainSeries(new Date(), 14, 'worsening');
const analysis = analyzePatterns(entries);
expect(analysis.trend).toBe('worsening');
expect(analysis.shouldAlert).toBe(true);
});
Strategy 3: The Crisis Testing Dashboard
For integration testing and QA, I built an in-app simulation dashboard:
const TEST_SCENARIOS: TestScenario[] = [
{
id: 'mild-stress',
name: 'Mild Stress Response',
description: 'Tests system response to mild stress indicators',
duration: 30,
stressLevel: 'mild',
simulatedBehaviors: {
rapidClicks: false,
erraticMovement: true,
longPauses: false,
frustrationIndicators: false,
},
expectedOutcomes: [
'UI slightly enlarges touch targets',
'Stress indicator appears',
'Subtle color adaptations',
],
},
{
id: 'emergency-crisis',
name: 'Emergency Crisis Mode',
description: 'Tests full emergency mode with all crisis features',
duration: 90,
stressLevel: 'emergency',
simulatedBehaviors: {
rapidClicks: true,
erraticMovement: true,
longPauses: true,
frustrationIndicators: true,
},
expectedOutcomes: [
'Full emergency interface active',
'All animations disabled',
'Maximum contrast enabled',
'Emergency contacts readily accessible',
],
},
];
The dashboard dispatches real DOM events to simulate user behavior:
const simulateStressBehaviors = useCallback((scenario: TestScenario) => {
// Simulate rapid clicks
if (scenario.simulatedBehaviors.rapidClicks) {
const clickEvents = Math.floor(Math.random() * 5) + 3;
for (let i = 0; i < clickEvents; i++) {
setTimeout(() => {
document.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}, i * 100);
}
}
// Simulate frustration indicators
if (scenario.simulatedBehaviors.frustrationIndicators) {
['Escape', 'Escape', 'Backspace'].forEach((key, index) => {
setTimeout(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key }));
}, index * 100);
});
}
}, []);
This isn't a unit testβit's a smoke test for empathy. You can watch the UI adapt in real-time and verify the experience matches intent.
Strategy 4: Consumer Component Testing
Instead of testing hooks in isolation, test components that consume the hooks:
function CrisisSupportConsumer() {
const crisis = useCrisisSupport();
return (
<div>
<div data-testid="crisis-enabled">{String(crisis.crisisDetectionEnabled)}</div>
<div data-testid="sensitivity">{crisis.sensitivity}</div>
<div data-testid="pain-threshold">{crisis.thresholds.painLevel}</div>
</div>
);
}
describe('useCrisisSupport', () => {
it('should return crisis support preferences with defaults', () => {
render(
<TestWrapper>
<CrisisSupportConsumer />
</TestWrapper>
);
expect(screen.getByTestId('crisis-enabled').textContent).toBe('true');
expect(screen.getByTestId('sensitivity').textContent).toBe('medium');
expect(screen.getByTestId('pain-threshold').textContent).toBe('8');
});
});
This tests the contract between hooks and componentsβthe actual integration point where bugs manifest.
Strategy 5: Time-Based Behavior with Fake Timers
Crisis detection involves time: how long between errors? How fast were those clicks? Use fake timers to control temporal behavior:
describe('crisis detection timing', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should require sustained stress before activating', () => {
const { result } = renderHook(() => useCrisisDetection());
// Single error burst shouldn't trigger crisis
result.current.trackError();
result.current.trackError();
result.current.trackError();
vi.advanceTimersByTime(1000);
expect(result.current.crisisLevel).toBe('none');
// Sustained errors over time should trigger
for (let i = 0; i < 5; i++) {
result.current.trackError();
vi.advanceTimersByTime(10000); // 10 second intervals
}
expect(result.current.crisisLevel).not.toBe('none');
});
it('should reset after recovery period', () => {
const { result } = renderHook(() => useCrisisDetection());
// Trigger crisis state
// ... (error injection)
expect(result.current.crisisLevel).toBe('moderate');
// Advance past recovery timeout
vi.advanceTimersByTime(300000); // 5 minutes of quiet
expect(result.current.crisisLevel).toBe('none');
});
});
Strategy 6: Outcome-Based Assertions
Don't test "is the user stressed?" Test "did the UI adapt correctly?"
test('should enable simplified mode in emergency', () => {
const { result } = renderHook(() => useCrisisDetection());
const { result: prefsResult } = renderHook(() => useTraumaInformed());
// Force emergency state
result.current.activateEmergencyMode();
// Assert UI adaptations, not internal state
expect(prefsResult.current.preferences.simplifiedMode).toBe(true);
expect(prefsResult.current.preferences.touchTargetSize).toBe('extra-large');
expect(prefsResult.current.preferences.confirmationLevel).toBe('high');
expect(prefsResult.current.preferences.showComfortPrompts).toBe(true);
});
The test isn't "is this a crisis?" It's "if this were a crisis, would the user be helped?"
Strategy 7: Complete Flow Assertions
Test the complete flow from trigger to resolution:
describe('CrisisModeProvider', () => {
it('should complete full crisis cycle', () => {
const { result } = renderHook(() => useCrisisMode());
// Initial state
expect(result.current.isCrisisModeActive).toBe(false);
// Activate
result.current.activateEmergencyMode();
expect(result.current.isCrisisModeActive).toBe(true);
expect(result.current.crisisFeatures.emergencyMode).toBe(true);
expect(result.current.crisisFeatures.cognitiveFogSupport).toBe(true);
// Deactivate
result.current.deactivateEmergencyMode();
expect(result.current.isCrisisModeActive).toBe(false);
expect(result.current.crisisFeatures.emergencyMode).toBe(false);
// But monitoring stays active
expect(result.current.crisisFeatures.stressResponsiveUI).toBe(true);
});
});
Common Pitfalls: What Not To Do
After a lot of trial and error, here's what I've learned to avoid:
β Don't Test for Emotional States Directly
// π« WRONG: Testing for internal human state
test('user feels overwhelmed', () => {
expect(user.emotionalState).toBe('overwhelmed');
});
// β
RIGHT: Testing for observable signal patterns
test('system detects overwhelm signals', () => {
injectSignals({ errorRate: 0.4, backNavigation: 5, helpRequests: 3 });
expect(crisisDetection.severity).toBe('moderate');
});
β Don't Hardcode Magic Thresholds in Tests
// π« WRONG: Brittle test tied to implementation
test('triggers at exactly 7 errors', () => {
for (let i = 0; i < 7; i++) trackError();
expect(crisis).toBe(true);
});
// β
RIGHT: Test behavior, not magic numbers
test('triggers after sustained error pattern', () => {
simulateFrustratedSession();
expect(crisisLevel).not.toBe('none');
});
β Don't Mock the Crisis Detection System Entirely
If you mock away the detection logic, you're not testing whether it worksβyou're testing whether your mock works.
// π« WRONG: Mock defeats the purpose
vi.mock('./useCrisisDetection', () => ({
useCrisisDetection: () => ({ crisisLevel: 'severe' })
}));
// β
RIGHT: Use the real system with controlled inputs
const { result } = renderHook(() => useCrisisDetection());
result.current.updatePainLevel(9);
result.current.trackError();
// Now test the real detection logic
β Don't Assume Synchronous State Updates
Crisis detection involves debouncing, timeouts, and async calculations. Tests that assume immediate state changes will flake.
// π« WRONG: Assumes synchronous
trackError();
expect(crisisLevel).toBe('mild'); // May still be 'none'
// β
RIGHT: Use waitFor or advance timers
trackError();
await waitFor(() => {
expect(crisisLevel).toBe('mild');
});
β Don't Test Only Happy Paths
The whole point of trauma-informed design is handling unhappy paths. Your test suite should have more crisis scenarios than calm ones.
β Don't Forget to Test Recovery
Activation gets all the attention. But deactivation is where bugs hide:
- Does the system actually calm down?
- Is there hysteresis to prevent flapping?
- Are preferences restored correctly?
When To Use Each Strategy
| Strategy | Best For | Speed | Coverage | Fidelity |
|---|---|---|---|---|
| 1. Fixture Injection | Testing preference combinations | β‘ Fast | Narrow | Low |
| 2. Signal Generators | Analytics, trend detection | β‘ Fast | Medium | Medium |
| 3. Crisis Dashboard | Visual QA, integration | π’ Slow | Wide | High |
| 4. Consumer Components | Hook contracts, rendering | β‘ Fast | Medium | Medium |
| 5. Fake Timers | Temporal logic, debouncing | β‘ Fast | Narrow | High |
| 6. Outcome Assertions | UI adaptation verification | β‘ Fast | Medium | Medium |
| 7. Flow Assertions | End-to-end state machines | β‘ Fast | Wide | High |
Quick decision guide:
- "Does this preference change the UI?" β Strategy 1 + 4
- "Does this pattern trigger an alert?" β Strategy 2 + 6
- "Does the timing feel right?" β Strategy 5
- "Does this actually help a real person?" β Strategy 3 (manual)
- "Does the whole flow work?" β Strategy 7
What You Can't Automate
Some things require human QA:
- Does the simplified interface actually feel simpler?
- Is the emergency mode calming or alarming?
- Are the touch targets big enough when your hands are shaking?
For these, I use the Crisis Testing Dashboard in manual review sessions. I've also done hallway testing with people who have chronic pain conditions (with their consent and appropriate support structures in place).
Automated tests catch regressions. Human testing catches failures of empathy.
The Testing Pyramid for Trauma-Informed Features
/\
/ \ Manual QA with lived-experience testers
/----\
/ \ Integration tests (Crisis Testing Dashboard)
/--------\
/ \ Component tests (Consumer components)
/--------------\
/ \ Unit tests (Signal processing, threshold logic)
/--------------------\
/ \ Fixtures (Pain patterns, mood progressions)
The base is fixturesβknown data patterns that represent real scenarios.
The middle layers test that your code correctly processes those patterns.
The top is human verification that the processed result actually helps.
Sample Test Suite Structure
src/test/
βββ fixtures/
β βββ makePainEntry.ts # Pain entry factory
β βββ sampleMoodData.ts # Mood progressions
β βββ index.ts # Exports
βββ test-utils.tsx # Providers and render helpers
βββ setup.ts # Global test configuration
src/components/accessibility/
βββ TraumaInformedHooks.ts
βββ TraumaInformedHooks.test.tsx # Unit tests for each hook
βββ useCrisisDetection.ts
βββ useCrisisDetection.test.ts # Signal processing tests
βββ CrisisTestingDashboard.tsx # Manual testing tool
βββ CrisisModeIntegration.tsx # Full integration
The Honest Answer
You can't fully automate testing for human experience.
What you can do:
- Test signal interpretation β Does high error rate + back navigation = elevated stress?
- Test state transitions β Does elevated stress β emergency mode?
- Test UI adaptations β Does emergency mode β bigger buttons + simpler language?
- Test recovery flows β Does resolution β gradual deactivation?
The gap between "test passes" and "user is helped" is where empathy lives. Automated tests keep the system mechanically correct. Human review keeps it humanely correct.
Both are required.
Complementary Accessibility Testing Tools
Trauma-informed testing doesn't replace accessibility testingβit builds on top of it. Here's what I use alongside the strategies above:
axe-core / @axe-core/react
Automated WCAG compliance checking. Catches the mechanical accessibility issues:
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('emergency mode has no accessibility violations', async () => {
const { container } = render(<EmergencyModeLayout />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Lighthouse CI
Performance + accessibility audits in CI. Critical for ensuring emergency mode doesn't accidentally break accessibility:
# .github/workflows/accessibility.yml
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
http://localhost:3000/
http://localhost:3000/?crisis=true
@testing-library/jest-dom
Accessibility-focused matchers:
// Verify crisis button is keyboard accessible
expect(screen.getByRole('button', { name: /emergency/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /emergency/i })).toHaveFocus();
Playwright Accessibility Snapshots
Full-page accessibility trees for regression testing:
test('emergency UI maintains accessibility', async ({ page }) => {
await page.goto('/?crisis=severe');
await expect(page).toMatchAriaSnapshot(`
- banner: Pain Tracker
- main:
- heading "Emergency Mode Active" [level=1]
- button "Call Crisis Line"
`);
});
Manual Tools
- NVDA / VoiceOver β Screen reader testing (can't be automated)
- High contrast mode β Windows/macOS built-in
-
Reduced motion β
prefers-reduced-motionsimulation in DevTools - Keyboard-only navigation β Unplug your mouse
The relationship: Accessibility tools verify you're not breaking standards. Trauma-informed tests verify you're exceeding them.
Resources
- React Testing Library β For component testing
- Vitest β Fast unit testing with fake timers
- jest-axe β Accessibility assertions
- axe-core β Automated WCAG testing
- Playwright Accessibility β E2E accessibility snapshots
- Pain Tracker source: github.com/CrisisCore-Systems/pain-tracker
Next in the series: "Offline Crisis Support: What Happens When the Network Dies at the Worst Moment"
If your pages look anything like mine:
- In Canada, call or text 9-8-8
- In the US, call or text 988
You're not untestable. You're just a system that needs different metrics.
Top comments (0)