UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

837 lines (691 loc) 24.2 kB
/** * Tests for ABTesting feature */ import { ABTesting } from '../ABTesting'; import { TinyTapAnalyticsConfig } from '../../types'; describe('ABTesting', () => { let abTesting: ABTesting; let config: TinyTapAnalyticsConfig; let mockSDK: any; let mockLocalStorage: Record<string, string>; beforeEach(() => { config = { apiKey: 'test-key', websiteId: 'test-website', endpoint: 'https://api.example.com', debug: false }; mockSDK = { userId: 'user-123', sessionId: 'session-456', track: jest.fn() }; // Mock localStorage mockLocalStorage = {}; Storage.prototype.getItem = jest.fn((key: string) => mockLocalStorage[key] || null); Storage.prototype.setItem = jest.fn((key: string, value: string) => { mockLocalStorage[key] = value; }); Storage.prototype.removeItem = jest.fn((key: string) => { delete mockLocalStorage[key]; }); // Mock fetch global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response) ); // Mock Math.random for consistent testing jest.spyOn(Math, 'random').mockReturnValue(0.5); abTesting = new ABTesting(config, mockSDK); }); afterEach(() => { jest.restoreAllMocks(); }); describe('Initialization', () => { it('should initialize without errors', () => { expect(abTesting).toBeInstanceOf(ABTesting); }); it('should load stored assignments from localStorage', () => { mockLocalStorage['tinytapanalytics_ab_tests'] = JSON.stringify({ 'test-1': 'variant-a', 'test-2': 'variant-b' }); abTesting = new ABTesting(config, mockSDK); expect(abTesting.getVariant('test-1')).toBeNull(); // Not running yet }); it('should initialize and load active tests from API', async () => { const mockTests = [ { id: 'test-1', name: 'Homepage Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 50, changes: [] }, { id: 'variant-a', name: 'Variant A', control: false, traffic: 50, changes: [] } ], allocation: { 'control': 50, 'variant-a': 50 }, status: 'running' as const, conversionGoals: ['signup'] } ]; global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockTests) } as Response) ); await abTesting.init(); expect(fetch).toHaveBeenCalledWith( 'https://api.example.com/api/v1/ab-tests', expect.objectContaining({ headers: { 'Authorization': 'Bearer test-key' } }) ); }); it('should handle API failure gracefully', async () => { global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))); await expect(abTesting.init()).resolves.not.toThrow(); }); it('should log debug messages when debug is enabled', async () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(); config.debug = true; abTesting = new ABTesting(config, mockSDK); await abTesting.init(); expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('A/B Testing initialized'), expect.any(Object) ); consoleLog.mockRestore(); }); }); describe('Test Creation', () => { it('should create a new test', () => { const testId = abTesting.createTest({ name: 'Button Color Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 50, changes: [] }, { id: 'blue', name: 'Blue Button', control: false, traffic: 50, changes: [] } ], allocation: { 'control': 50, 'blue': 50 }, conversionGoals: ['click'] }); expect(testId).toBeDefined(); expect(testId).toContain('test_'); }); it('should assign draft status to new tests', () => { const testId = abTesting.createTest({ name: 'Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, conversionGoals: [] }); const tests = abTesting.getActiveTests(); expect(tests.length).toBe(0); // Not running yet }); it('should log test creation in debug mode', () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(); config.debug = true; abTesting = new ABTesting(config, mockSDK); abTesting.createTest({ name: 'Test', variants: [{ id: 'v1', name: 'V1', control: true, traffic: 100, changes: [] }], allocation: { 'v1': 100 }, conversionGoals: [] }); expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('A/B Test created'), expect.any(Object) ); consoleLog.mockRestore(); }); }); describe('Test Lifecycle', () => { let testId: string; beforeEach(() => { testId = abTesting.createTest({ name: 'Lifecycle Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 50, changes: [] }, { id: 'variant', name: 'Variant', control: false, traffic: 50, changes: [] } ], allocation: { 'control': 50, 'variant': 50 }, conversionGoals: ['signup'] }); }); it('should start a test', () => { const result = abTesting.startTest(testId); expect(result).toBe(true); expect(mockSDK.track).toHaveBeenCalledWith( 'ab_test_started', expect.objectContaining({ testId, testName: 'Lifecycle Test' }) ); }); it('should stop a test', () => { abTesting.startTest(testId); const result = abTesting.stopTest(testId); expect(result).toBe(true); expect(mockSDK.track).toHaveBeenCalledWith( 'ab_test_stopped', expect.objectContaining({ testId, testName: 'Lifecycle Test' }) ); }); it('should return false when starting non-existent test', () => { const result = abTesting.startTest('invalid-id'); expect(result).toBe(false); }); it('should return false when stopping non-existent test', () => { const result = abTesting.stopTest('invalid-id'); expect(result).toBe(false); }); it('should include test in active tests when running', () => { abTesting.startTest(testId); const activeTests = abTesting.getActiveTests(); expect(activeTests.length).toBe(1); expect(activeTests[0].id).toBe(testId); }); it('should exclude test from active tests when stopped', () => { abTesting.startTest(testId); abTesting.stopTest(testId); const activeTests = abTesting.getActiveTests(); expect(activeTests.length).toBe(0); }); }); describe('Variant Assignment', () => { let testId: string; beforeEach(() => { testId = abTesting.createTest({ name: 'Assignment Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 50, changes: [] }, { id: 'variant-a', name: 'Variant A', control: false, traffic: 50, changes: [] } ], allocation: { 'control': 50, 'variant-a': 50 }, conversionGoals: ['signup'] }); abTesting.startTest(testId); }); it('should assign user to a variant', () => { const variantId = abTesting.getVariant(testId); expect(variantId).toBeDefined(); expect(['control', 'variant-a']).toContain(variantId); }); it('should return consistent variant for same user', () => { const variant1 = abTesting.getVariant(testId); const variant2 = abTesting.getVariant(testId); expect(variant1).toBe(variant2); }); it('should store assignment in localStorage', () => { abTesting.getVariant(testId); expect(Storage.prototype.setItem).toHaveBeenCalledWith( 'tinytapanalytics_ab_tests', expect.any(String) ); }); it('should track assignment event', () => { abTesting.getVariant(testId); expect(mockSDK.track).toHaveBeenCalledWith( 'ab_test_assignment', expect.objectContaining({ testId, testName: 'Assignment Test', variantId: expect.any(String) }) ); }); it('should return null for non-running test', () => { abTesting.stopTest(testId); const variantId = abTesting.getVariant(testId); expect(variantId).toBeNull(); }); it('should return null for non-existent test', () => { const variantId = abTesting.getVariant('invalid-id'); expect(variantId).toBeNull(); }); }); describe('Targeting Rules', () => { it('should match URL targeting rule with equals operator', () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'https://example.com/test' } }); const testId = abTesting.createTest({ name: 'URL Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, targetingRules: [ { type: 'url', operator: 'equals', value: 'https://example.com/test' } ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBe('control'); }); it('should match URL targeting rule with contains operator', () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'https://example.com/pricing' } }); const testId = abTesting.createTest({ name: 'URL Contains Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, targetingRules: [ { type: 'url', operator: 'contains', value: 'pricing' } ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBe('control'); }); it('should not match when URL targeting fails', () => { Object.defineProperty(window, 'location', { writable: true, value: { href: 'https://example.com/other' } }); const testId = abTesting.createTest({ name: 'URL Fail Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, targetingRules: [ { type: 'url', operator: 'contains', value: 'pricing' } ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBeNull(); }); it('should match device targeting rule', () => { Object.defineProperty(navigator, 'userAgent', { writable: true, value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)' }); const testId = abTesting.createTest({ name: 'Device Test', variants: [ { id: 'mobile', name: 'Mobile', control: true, traffic: 100, changes: [] } ], allocation: { 'mobile': 100 }, targetingRules: [ { type: 'device', operator: 'contains', value: 'iphone' } ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBe('mobile'); }); it('should match traffic targeting rule', () => { jest.spyOn(Math, 'random').mockReturnValue(0.3); // 30% const testId = abTesting.createTest({ name: 'Traffic Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, targetingRules: [ { type: 'traffic', operator: 'less', value: 50 } // < 50% ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBe('control'); }); it('should not match when traffic targeting fails', () => { jest.spyOn(Math, 'random').mockReturnValue(0.7); // 70% const testId = abTesting.createTest({ name: 'Traffic Fail Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, targetingRules: [ { type: 'traffic', operator: 'less', value: 50 } // < 50% ], conversionGoals: [] }); abTesting.startTest(testId); const variant = abTesting.getVariant(testId); expect(variant).toBeNull(); }); }); describe('Applying Variants', () => { let testId: string; beforeEach(() => { // Mock querySelectorAll to avoid errors during test start document.querySelectorAll = jest.fn(() => [] as any); testId = abTesting.createTest({ name: 'Apply Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 50, changes: [] }, { id: 'variant-a', name: 'Variant A', control: false, traffic: 50, changes: [ { type: 'css', selector: '.button', property: 'color', value: 'blue' } ] } ], allocation: { 'control': 50, 'variant-a': 50 }, conversionGoals: [] }); abTesting.startTest(testId); }); it('should apply CSS changes', () => { const mockElement = { style: { setProperty: jest.fn() } }; document.querySelectorAll = jest.fn(() => [mockElement] as any); abTesting.applyVariant(testId, 'variant-a'); expect(mockElement.style.setProperty).toHaveBeenCalledWith('color', 'blue'); }); it('should not apply changes for control variant', () => { const mockElement = { style: { setProperty: jest.fn() } }; document.querySelectorAll = jest.fn(() => [mockElement] as any); abTesting.applyVariant(testId, 'control'); expect(mockElement.style.setProperty).not.toHaveBeenCalled(); }); it('should apply HTML changes', () => { const testId = abTesting.createTest({ name: 'HTML Test', variants: [ { id: 'variant', name: 'Variant', control: false, traffic: 100, changes: [ { type: 'html', selector: '.title', value: '<h1>New Title</h1>' } ] } ], allocation: { 'variant': 100 }, conversionGoals: [] }); const mockElement = { innerHTML: '' }; document.querySelectorAll = jest.fn(() => [mockElement] as any); abTesting.applyVariant(testId, 'variant'); expect(mockElement.innerHTML).toBe('<h1>New Title</h1>'); }); it('should apply JavaScript changes', () => { const testId = abTesting.createTest({ name: 'JS Test', variants: [ { id: 'variant', name: 'Variant', control: false, traffic: 100, changes: [ { type: 'javascript', value: 'window.testVar = "modified"' } ] } ], allocation: { 'variant': 100 }, conversionGoals: [] }); (window as any).testVar = 'original'; abTesting.applyVariant(testId, 'variant'); expect((window as any).testVar).toBe('modified'); }); it('should handle JavaScript errors gracefully', () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(); config.debug = true; abTesting = new ABTesting(config, mockSDK); const testId = abTesting.createTest({ name: 'JS Error Test', variants: [ { id: 'variant', name: 'Variant', control: false, traffic: 100, changes: [ { type: 'javascript', value: 'throw new Error("test error")' } ] } ], allocation: { 'variant': 100 }, conversionGoals: [] }); expect(() => abTesting.applyVariant(testId, 'variant')).not.toThrow(); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); }); describe('Conversion Tracking', () => { let testId: string; beforeEach(() => { testId = abTesting.createTest({ name: 'Conversion Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, conversionGoals: ['signup', 'purchase'] }); abTesting.startTest(testId); abTesting.getVariant(testId); // Assign variant }); it('should track conversion', () => { abTesting.trackConversion(testId, 'signup'); expect(mockSDK.track).toHaveBeenCalledWith( 'ab_test_conversion', expect.objectContaining({ testId, goal: 'signup', variantId: 'control' }) ); }); it('should track conversion with value', () => { abTesting.trackConversion(testId, 'purchase', 99.99); expect(mockSDK.track).toHaveBeenCalledWith( 'ab_test_conversion', expect.objectContaining({ testId, goal: 'purchase', value: 99.99 }) ); }); it('should include time to convert', () => { // Create a fresh test instance with spy on Date.now const dateSpy = jest.spyOn(Date, 'now'); // Setup return values for different Date.now() calls dateSpy .mockReturnValueOnce(1000) // generateTestId .mockReturnValueOnce(1000) // assignedAt in getVariant .mockReturnValueOnce(3000) // conversion timestamp in conversions array .mockReturnValueOnce(3000) // convertedAt in track call .mockReturnValueOnce(3000); // timeToConvert calculation const freshSDK = { userId: 'user-convert', sessionId: 'session-convert', track: jest.fn() }; const freshABTesting = new ABTesting(config, freshSDK); const newTestId = freshABTesting.createTest({ name: 'Time Convert Test', variants: [ { id: 'v1', name: 'V1', control: true, traffic: 100, changes: [] } ], allocation: { 'v1': 100 }, conversionGoals: ['goal'] }); freshABTesting.startTest(newTestId); freshABTesting.getVariant(newTestId); // assignedAt = 1000 freshSDK.track.mockClear(); // Clear assignment and start tracking freshABTesting.trackConversion(newTestId, 'goal'); expect(freshSDK.track).toHaveBeenCalledWith( 'ab_test_conversion', expect.objectContaining({ timeToConvert: 2000 // 3000 - 1000 }) ); dateSpy.mockRestore(); }); it('should not track conversion for unassigned test', () => { // Create a completely fresh ABTesting instance to avoid test pollution const freshSDK = { userId: 'user-999', sessionId: 'session-999', track: jest.fn() }; const freshABTesting = new ABTesting(config, freshSDK); const anotherTestId = freshABTesting.createTest({ name: 'Another Test', variants: [ { id: 'v1', name: 'V1', control: true, traffic: 100, changes: [] } ], allocation: { 'v1': 100 }, conversionGoals: [] }); // Don't start the test - keep it in draft status freshABTesting.trackConversion(anotherTestId, 'goal'); // Should not track since test is not running and user not assigned expect(freshSDK.track).not.toHaveBeenCalled(); }); }); describe('Test Results', () => { let testId: string; beforeEach(() => { testId = abTesting.createTest({ name: 'Results Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { 'control': 100 }, conversionGoals: ['signup'] }); abTesting.startTest(testId); }); it('should return test results', () => { abTesting.getVariant(testId); const results = abTesting.getTestResults(testId); expect(results).toEqual({ testId, variantId: 'control', userId: 'user-123', sessionId: 'session-456', assignedAt: expect.any(Number), conversions: [] }); }); it('should include conversions in results', () => { abTesting.getVariant(testId); abTesting.trackConversion(testId, 'signup', 50); const results = abTesting.getTestResults(testId); expect(results?.conversions).toHaveLength(1); expect(results?.conversions[0]).toEqual({ goal: 'signup', timestamp: expect.any(Number), value: 50 }); }); it('should return null for non-existent test results', () => { const results = abTesting.getTestResults('invalid-id'); expect(results).toBeNull(); }); }); describe('LocalStorage Persistence', () => { it('should handle localStorage errors gracefully', () => { Storage.prototype.setItem = jest.fn(() => { throw new Error('Storage full'); }); const testId = abTesting.createTest({ name: 'Storage Test', variants: [ { id: 'v1', name: 'V1', control: true, traffic: 100, changes: [] } ], allocation: { 'v1': 100 }, conversionGoals: [] }); abTesting.startTest(testId); expect(() => abTesting.getVariant(testId)).not.toThrow(); }); it('should handle localStorage read errors gracefully', () => { Storage.prototype.getItem = jest.fn(() => { throw new Error('Storage error'); }); expect(() => new ABTesting(config, mockSDK)).not.toThrow(); }); }); describe('Debug Logging', () => { beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(); jest.spyOn(console, 'error').mockImplementation(); }); it('should handle initialization errors in debug mode', async () => { // Mock fetch to throw an error (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); const debugConfig = { ...config, debug: true }; const debugABTesting = new ABTesting(debugConfig, mockSDK); // Should not throw even with error await expect(debugABTesting.init()).resolves.not.toThrow(); }); it('should log test start in debug mode', () => { const debugConfig = { ...config, debug: true }; const debugABTesting = new ABTesting(debugConfig, mockSDK); const testId = debugABTesting.createTest({ name: 'Debug Test', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { control: 100 } }); debugABTesting.startTest(testId); expect(console.log).toHaveBeenCalledWith( 'TinyTapAnalytics: A/B Test started', 'Debug Test' ); }); it('should log test stop in debug mode', () => { const debugConfig = { ...config, debug: true }; const debugABTesting = new ABTesting(debugConfig, mockSDK); const testId = debugABTesting.createTest({ name: 'Debug Test Stop', variants: [ { id: 'control', name: 'Control', control: true, traffic: 100, changes: [] } ], allocation: { control: 100 } }); debugABTesting.startTest(testId); debugABTesting.stopTest(testId); expect(console.log).toHaveBeenCalledWith( 'TinyTapAnalytics: A/B Test stopped', 'Debug Test Stop' ); }); }); });