UNPKG

@tinytapanalytics/sdk

Version:

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

927 lines (731 loc) 29.2 kB
/** * Tests for AdvancedAnalytics feature */ import { AdvancedAnalytics } from '../AdvancedAnalytics'; import { TinyTapAnalyticsConfig } from '../../types/index'; describe('AdvancedAnalytics', () => { let advancedAnalytics: AdvancedAnalytics; let mockConfig: TinyTapAnalyticsConfig; let mockSdk: any; beforeEach(() => { mockConfig = { apiKey: 'test-key', endpoint: 'https://api.test.com', debug: false }; mockSdk = { track: jest.fn() }; // Mock localStorage Object.defineProperty(global, 'localStorage', { value: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn() }, writable: true, configurable: true }); // Mock MutationObserver global.MutationObserver = jest.fn().mockImplementation((callback) => ({ observe: jest.fn(), disconnect: jest.fn(), takeRecords: jest.fn() })); jest.spyOn(document, 'addEventListener').mockImplementation(() => {}); jest.spyOn(window, 'addEventListener').mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); describe('initialization', () => { it('should initialize session data', () => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); const sessionData = advancedAnalytics.getSessionData(); expect(sessionData.id).toContain('ciq_session_'); expect(sessionData.startTime).toBeGreaterThan(0); expect(sessionData.lastActivity).toBeGreaterThan(0); expect(sessionData.pageViews).toBe(0); expect(sessionData.events).toBe(0); expect(sessionData.duration).toBe(0); }); it('should setup event listeners for session tracking', () => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); expect(document.addEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), expect.any(Object)); expect(document.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object)); expect(document.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), expect.any(Object)); expect(document.addEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function), expect.any(Object)); expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); }); }); describe('trackPageView', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should track page view with default URL', () => { advancedAnalytics.trackPageView(); expect(mockSdk.track).toHaveBeenCalledWith('enhanced_page_view', { url: window.location.href, title: document.title, referrer: document.referrer, timestamp: expect.any(Number), viewport: { width: window.innerWidth, height: window.innerHeight }, screen: { width: screen.width, height: screen.height }, sessionId: expect.stringContaining('ciq_session_'), pageNumber: 1 }); }); it('should track page view with custom URL', () => { advancedAnalytics.trackPageView('https://example.com/custom'); expect(mockSdk.track).toHaveBeenCalledWith('enhanced_page_view', expect.objectContaining({ url: 'https://example.com/custom', pageNumber: 1 })); }); it('should increment page view count', () => { advancedAnalytics.trackPageView(); advancedAnalytics.trackPageView(); const sessionData = advancedAnalytics.getSessionData(); expect(sessionData.pageViews).toBe(2); }); it('should add page view to user journey', () => { advancedAnalytics.trackPageView(); const journey = advancedAnalytics.getUserJourney(); expect(journey.length).toBe(1); expect(journey[0].event).toBe('page_view'); }); }); describe('trackEngagement', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); // Mock scroll properties Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); Object.defineProperty(window, 'pageYOffset', { configurable: true, value: 0 }); }); it('should track user engagement', () => { advancedAnalytics.trackEngagement(); expect(mockSdk.track).toHaveBeenCalledWith('user_engagement', { timeOnPage: expect.any(Number), scrollDepth: expect.any(Number), clickCount: 0, sessionDuration: expect.any(Number), pageViews: 0, sessionId: expect.stringContaining('ciq_session_') }); }); }); describe('setupFunnelAnalysis', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should setup funnel analysis with selectors', () => { const steps = [ { name: 'Step 1', selector: '.step-1' }, { name: 'Step 2', selector: '.step-2' }, { name: 'Step 3', selector: '.step-3' } ]; advancedAnalytics.setupFunnelAnalysis(steps); expect(global.MutationObserver).toHaveBeenCalled(); }); it('should setup funnel analysis with URLs', () => { const steps = [ { name: 'Landing', url: '/landing' }, { name: 'Signup', url: '/signup' }, { name: 'Complete', url: '/complete' } ]; advancedAnalytics.setupFunnelAnalysis(steps); // Should not crash expect(true).toBe(true); }); }); describe('trackCohort', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should track cohort with user attributes', () => { const userAttributes = { plan: 'premium', signupDate: '2024-01-01' }; advancedAnalytics.trackCohort('Premium Users', userAttributes); expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', { cohortName: 'Premium Users', userAttributes, sessionId: expect.stringContaining('ciq_session_'), firstVisit: expect.any(Boolean), timestamp: expect.any(Number) }); }); it('should track cohort without user attributes', () => { advancedAnalytics.trackCohort('All Users'); expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', { cohortName: 'All Users', userAttributes: {}, sessionId: expect.stringContaining('ciq_session_'), firstVisit: expect.any(Boolean), timestamp: expect.any(Number) }); }); it('should detect first visit', () => { (localStorage.getItem as jest.Mock).mockReturnValue(null); advancedAnalytics.trackCohort('New Users'); expect(localStorage.setItem).toHaveBeenCalledWith('tinytapanalytics_visited', 'true'); expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({ firstVisit: true })); }); it('should detect returning visit', () => { (localStorage.getItem as jest.Mock).mockReturnValue('true'); advancedAnalytics.trackCohort('Returning Users'); expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({ firstVisit: false })); }); it('should handle localStorage errors', () => { (localStorage.getItem as jest.Mock).mockImplementation(() => { throw new Error('localStorage not available'); }); advancedAnalytics.trackCohort('Users'); expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({ firstVisit: false })); }); }); describe('trackAttribution', () => { beforeEach(() => { // Reset window.location to default delete (window as any).location; window.location = { ...window.location, search: '' } as any; advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should track attribution from Google', () => { Object.defineProperty(document, 'referrer', { value: 'https://www.google.com/search', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', { source: 'google', medium: 'organic', campaign: null, referrer: 'https://www.google.com/search', landingPage: window.location.href, sessionId: expect.stringContaining('ciq_session_'), timestamp: expect.any(Number) }); }); it('should track attribution from Facebook', () => { Object.defineProperty(document, 'referrer', { value: 'https://www.facebook.com/post', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'facebook', medium: 'social' })); }); it('should track direct traffic', () => { Object.defineProperty(document, 'referrer', { value: '', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'direct', medium: 'direct' })); }); it('should track referral traffic', () => { Object.defineProperty(document, 'referrer', { value: 'https://example.com/page', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'referral', medium: 'referral' })); }); it('should extract UTM campaign', () => { Object.defineProperty(window, 'location', { value: { ...window.location, search: '?utm_campaign=summer-sale&utm_medium=email' }, configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ campaign: 'summer-sale', medium: 'email' })); }); }); describe('trackConversionFunnel', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should track conversion funnel step', () => { advancedAnalytics.trackConversionFunnel('Checkout', 'Payment Info', 99.99); expect(mockSdk.track).toHaveBeenCalledWith('conversion_funnel', { funnelName: 'Checkout', stepName: 'Payment Info', value: 99.99, sessionId: expect.stringContaining('ciq_session_'), userJourney: expect.any(Array), timestamp: expect.any(Number) }); }); it('should track conversion funnel step without value', () => { advancedAnalytics.trackConversionFunnel('Signup', 'Email'); expect(mockSdk.track).toHaveBeenCalledWith('conversion_funnel', expect.objectContaining({ funnelName: 'Signup', stepName: 'Email', value: 0 })); }); }); describe('trackUserSegment', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should track user segment with properties', () => { const properties = { industry: 'technology', companySize: '50-200' }; advancedAnalytics.trackUserSegment('Enterprise', properties); expect(mockSdk.track).toHaveBeenCalledWith('user_segment', { segment: 'Enterprise', properties, sessionId: expect.stringContaining('ciq_session_'), sessionData: expect.any(Object), timestamp: expect.any(Number) }); }); it('should track user segment without properties', () => { advancedAnalytics.trackUserSegment('Free Users'); expect(mockSdk.track).toHaveBeenCalledWith('user_segment', expect.objectContaining({ segment: 'Free Users', properties: {} })); }); }); describe('user journey', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should add custom events to user journey', () => { advancedAnalytics.addToUserJourney('button_click', { button: 'signup' }); const journey = advancedAnalytics.getUserJourney(); expect(journey.length).toBe(1); expect(journey[0].event).toBe('button_click'); expect(journey[0].data).toEqual({ button: 'signup' }); }); it('should increment events count', () => { advancedAnalytics.addToUserJourney('event1', {}); advancedAnalytics.addToUserJourney('event2', {}); const sessionData = advancedAnalytics.getSessionData(); expect(sessionData.events).toBe(2); }); it('should limit journey to 50 events', () => { for (let i = 0; i < 60; i++) { advancedAnalytics.addToUserJourney(`event${i}`, { index: i }); } const journey = advancedAnalytics.getUserJourney(); expect(journey.length).toBe(50); expect(journey[0].data.index).toBe(10); // First 10 should be removed }); it('should return copy of user journey', () => { advancedAnalytics.addToUserJourney('test', {}); const journey1 = advancedAnalytics.getUserJourney(); const journey2 = advancedAnalytics.getUserJourney(); expect(journey1).toEqual(journey2); expect(journey1).not.toBe(journey2); // Different objects }); }); describe('session data', () => { beforeEach(() => { advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should return copy of session data', () => { const sessionData1 = advancedAnalytics.getSessionData(); const sessionData2 = advancedAnalytics.getSessionData(); expect(sessionData1).toEqual(sessionData2); expect(sessionData1).not.toBe(sessionData2); // Different objects }); it('should update session duration', () => { jest.useFakeTimers(); const initialData = advancedAnalytics.getSessionData(); expect(initialData.duration).toBe(0); jest.advanceTimersByTime(5000); advancedAnalytics.trackPageView(); const updatedData = advancedAnalytics.getSessionData(); expect(updatedData.duration).toBeGreaterThan(0); jest.useRealTimers(); }); }); describe('traffic source detection', () => { beforeEach(() => { // Reset window.location to default delete (window as any).location; window.location = { ...window.location, search: '' } as any; advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should detect Bing as source', () => { Object.defineProperty(document, 'referrer', { value: 'https://www.bing.com/search', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'bing', medium: 'organic' })); }); it('should detect Yahoo as source', () => { Object.defineProperty(document, 'referrer', { value: 'https://search.yahoo.com', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'yahoo', medium: 'organic' })); }); it('should detect Twitter as source', () => { Object.defineProperty(document, 'referrer', { value: 'https://twitter.com/post', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'twitter', medium: 'social' })); }); it('should detect LinkedIn as source', () => { Object.defineProperty(document, 'referrer', { value: 'https://www.linkedin.com/feed', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'linkedin', medium: 'social' })); }); it('should detect Instagram as source', () => { Object.defineProperty(document, 'referrer', { value: 'https://www.instagram.com/post', configurable: true }); advancedAnalytics.trackAttribution(); expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({ source: 'instagram', medium: 'social' })); }); }); describe('session ID generation', () => { it('should generate unique session IDs', () => { const analytics1 = new AdvancedAnalytics(mockConfig, mockSdk); const analytics2 = new AdvancedAnalytics(mockConfig, mockSdk); const session1 = analytics1.getSessionData(); const session2 = analytics2.getSessionData(); expect(session1.id).not.toBe(session2.id); expect(session1.id).toMatch(/^ciq_session_\d+_[a-z0-9]+$/); expect(session2.id).toMatch(/^ciq_session_\d+_[a-z0-9]+$/); }); }); describe('event listener callbacks', () => { let eventListeners: Map<string, Function[]>; let documentListeners: Map<string, Function[]>; let windowListeners: Map<string, Function[]>; beforeEach(() => { eventListeners = new Map(); documentListeners = new Map(); windowListeners = new Map(); // Capture event listeners jest.spyOn(document, 'addEventListener').mockImplementation((event: string, handler: any) => { if (!documentListeners.has(event)) { documentListeners.set(event, []); } documentListeners.get(event)!.push(handler); }); jest.spyOn(window, 'addEventListener').mockImplementation((event: string, handler: any) => { if (!windowListeners.has(event)) { windowListeners.set(event, []); } windowListeners.get(event)!.push(handler); }); advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should update session activity on visibilitychange when not hidden', () => { const initialData = advancedAnalytics.getSessionData(); // Mock document as visible Object.defineProperty(document, 'hidden', { configurable: true, value: false }); // Trigger visibilitychange event const handlers = documentListeners.get('visibilitychange') || []; handlers.forEach(handler => handler()); const updatedData = advancedAnalytics.getSessionData(); expect(updatedData.lastActivity).toBeGreaterThanOrEqual(initialData.lastActivity); }); it('should not update session when document is hidden', () => { const initialActivity = advancedAnalytics.getSessionData().lastActivity; // Mock document as hidden Object.defineProperty(document, 'hidden', { configurable: true, value: true }); // Trigger visibilitychange event const handlers = documentListeners.get('visibilitychange') || []; handlers.forEach(handler => handler()); const updatedActivity = advancedAnalytics.getSessionData().lastActivity; expect(updatedActivity).toBe(initialActivity); }); it('should update session activity on click', async () => { const initialActivity = advancedAnalytics.getSessionData().lastActivity; await new Promise(resolve => setTimeout(resolve, 100)); // Trigger click event const handlers = documentListeners.get('click') || []; handlers.forEach(handler => handler()); const updatedActivity = advancedAnalytics.getSessionData().lastActivity; expect(updatedActivity).toBeGreaterThan(initialActivity); }); it('should update session activity on scroll', async () => { const initialActivity = advancedAnalytics.getSessionData().lastActivity; await new Promise(resolve => setTimeout(resolve, 100)); // Trigger scroll event const handlers = documentListeners.get('scroll') || []; handlers.forEach(handler => handler()); const updatedActivity = advancedAnalytics.getSessionData().lastActivity; expect(updatedActivity).toBeGreaterThan(initialActivity); }); it('should update session activity on keydown', async () => { const initialActivity = advancedAnalytics.getSessionData().lastActivity; await new Promise(resolve => setTimeout(resolve, 100)); // Trigger keydown event const handlers = documentListeners.get('keydown') || []; handlers.forEach(handler => handler()); const updatedActivity = advancedAnalytics.getSessionData().lastActivity; expect(updatedActivity).toBeGreaterThan(initialActivity); }); it('should update session activity on mousemove', async () => { const initialActivity = advancedAnalytics.getSessionData().lastActivity; await new Promise(resolve => setTimeout(resolve, 100)); // Trigger mousemove event const handlers = documentListeners.get('mousemove') || []; handlers.forEach(handler => handler()); const updatedActivity = advancedAnalytics.getSessionData().lastActivity; expect(updatedActivity).toBeGreaterThan(initialActivity); }); it('should end session on beforeunload', () => { mockSdk.track.mockClear(); // Trigger beforeunload event const handlers = windowListeners.get('beforeunload') || []; handlers.forEach(handler => handler()); expect(mockSdk.track).toHaveBeenCalledWith('session_end', expect.objectContaining({ id: expect.stringContaining('ciq_session_'), userJourney: expect.any(Array), endTime: expect.any(Number) })); }); }); describe('funnel analysis with elements', () => { let mutationObserverCallback: Function; beforeEach(() => { global.MutationObserver = jest.fn().mockImplementation((callback) => { mutationObserverCallback = callback; return { observe: jest.fn(), disconnect: jest.fn(), takeRecords: jest.fn() }; }); advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); }); it('should observe and track funnel step when element appears', () => { const steps = [{ name: 'Step 1', selector: '.step-1' }]; advancedAnalytics.setupFunnelAnalysis(steps); // Simulate element appearing in DOM const mockElement = document.createElement('button'); mockElement.className = 'step-1'; document.body.appendChild(mockElement); // Simulate querySelector finding the element jest.spyOn(document, 'querySelector').mockReturnValue(mockElement); // Trigger MutationObserver callback mutationObserverCallback(); // Simulate click on the element mockElement.click(); expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({ stepName: 'Step 1', stepIndex: 0, funnelData: expect.objectContaining({ conversions: expect.arrayContaining([1]) }) })); mockElement.remove(); }); it('should track existing funnel step element', () => { // Create element before setup const mockElement = document.createElement('button'); mockElement.className = 'existing-step'; document.body.appendChild(mockElement); const querySelectorSpy = jest.spyOn(document, 'querySelector'); querySelectorSpy.mockReturnValue(mockElement); const steps = [{ name: 'Existing Step', selector: '.existing-step' }]; advancedAnalytics.setupFunnelAnalysis(steps); // Should have added click listener to existing element mockElement.click(); expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({ stepName: 'Existing Step' })); mockElement.remove(); querySelectorSpy.mockRestore(); }); it('should track URL-based funnel step when URL matches', () => { Object.defineProperty(window, 'location', { value: { ...window.location, href: 'https://example.com/checkout' }, configurable: true }); const steps = [{ name: 'Checkout', url: '/checkout' }]; advancedAnalytics.setupFunnelAnalysis(steps); expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({ stepName: 'Checkout', stepIndex: 0 })); }); it('should not track URL-based funnel step when URL does not match', () => { Object.defineProperty(window, 'location', { value: { ...window.location, href: 'https://example.com/home' }, configurable: true }); mockSdk.track.mockClear(); const steps = [{ name: 'Checkout', url: '/checkout' }]; advancedAnalytics.setupFunnelAnalysis(steps); const funnelStepCalls = mockSdk.track.mock.calls.filter( call => call[0] === 'funnel_step' ); expect(funnelStepCalls.length).toBe(0); }); it('should not observe funnel step without selector or url', () => { const steps = [{ name: 'No Selector Step' }]; const mutationCalls = (global.MutationObserver as jest.Mock).mock.calls.length; advancedAnalytics.setupFunnelAnalysis(steps); // MutationObserver should not be called for steps without selector or url expect((global.MutationObserver as jest.Mock).mock.calls.length).toBe(mutationCalls); }); }); describe('funnel step recording', () => { beforeEach(() => { // Reset MutationObserver for funnel tests global.MutationObserver = jest.fn().mockImplementation(() => ({ observe: jest.fn(), disconnect: jest.fn(), takeRecords: jest.fn() })); advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk); mockSdk.track.mockClear(); }); it('should calculate dropoff rates', () => { const mockElement1 = document.createElement('button'); mockElement1.className = 'step-1'; const mockElement2 = document.createElement('button'); mockElement2.className = 'step-2'; document.body.appendChild(mockElement1); document.body.appendChild(mockElement2); const querySelectorSpy = jest.spyOn(document, 'querySelector'); querySelectorSpy.mockReturnValueOnce(mockElement1); querySelectorSpy.mockReturnValueOnce(mockElement2); const steps = [ { name: 'Step 1', selector: '.step-1' }, { name: 'Step 2', selector: '.step-2' } ]; advancedAnalytics.setupFunnelAnalysis(steps); // Complete step 1 twice mockElement1.click(); mockElement1.click(); // Complete step 2 once mockElement2.click(); // Check funnel data - should have tracked 3 funnel_step events const funnelCalls = mockSdk.track.mock.calls.filter(call => call[0] === 'funnel_step'); expect(funnelCalls.length).toBe(3); const lastCall = funnelCalls[funnelCalls.length - 1]; const funnelData = lastCall[1].funnelData; expect(funnelData.conversions[0]).toBe(2); expect(funnelData.conversions[1]).toBe(1); expect(funnelData.dropoffs[0]).toBe(1); // 2 - 1 = 1 dropoff mockElement1.remove(); mockElement2.remove(); querySelectorSpy.mockRestore(); }); it('should calculate conversion rate', () => { const mockElement1 = document.createElement('button'); mockElement1.className = 'start'; const mockElement2 = document.createElement('button'); mockElement2.className = 'end'; document.body.appendChild(mockElement1); document.body.appendChild(mockElement2); const querySelectorSpy = jest.spyOn(document, 'querySelector'); querySelectorSpy.mockReturnValueOnce(mockElement1); querySelectorSpy.mockReturnValueOnce(mockElement2); const steps = [ { name: 'Start', selector: '.start' }, { name: 'End', selector: '.end' } ]; advancedAnalytics.setupFunnelAnalysis(steps); // 4 people start mockElement1.click(); mockElement1.click(); mockElement1.click(); mockElement1.click(); // 1 person completes mockElement2.click(); // Check conversion rate const funnelCalls = mockSdk.track.mock.calls.filter(call => call[0] === 'funnel_step'); const lastCall = funnelCalls[funnelCalls.length - 1]; const funnelData = lastCall[1].funnelData; expect(funnelData.conversionRate).toBe(25); // 1/4 * 100 = 25% mockElement1.remove(); mockElement2.remove(); querySelectorSpy.mockRestore(); }); }); });