@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
text/typescript
/**
* 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'
);
});
});
});