Skip to content

Conversation

@MatiPl01
Copy link
Member

@MatiPl01 MatiPl01 commented Dec 6, 2025

Summary

This PR adds web-specific jest setup with jsdom to allow writing tests for the web. It also includes Platform mocking os that the Platform no longer has to be mocked manually in tests. To create web-specific tests, we have to use the .web.test.(ts|tsx|js|jsx) extension and the test will automatically run with web APIs mocked.

Note

To test components/hooks/etc. in web-specific tests, please use helpers from the @testing-library/react, not from the @testing-library/react-native as the component's code is automatically transformed to the web's DOM and react-native's matchers no longer work.

Test plan

Web test to verify availability of web APIs. It can be added as the packages/react-native-reanimated/__tests__/example.web.test.tsx file to the project and run with jest tests to verify if everything works as expected.

Test source code
import { fireEvent, render, screen } from '@testing-library/react';
import type React from 'react';
import { Button, Platform, Text, View } from 'react-native';

import Animated, {
  createAnimatedComponent,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from '../src';

describe('Web environment setup', () => {
  describe('Web primitives availability', () => {
    test('window should be available', () => {
      expect(typeof window).toBe('object');
      expect(window).toBeDefined();
    });

    test('document should be available', () => {
      expect(typeof document).toBe('object');
      expect(document).toBeDefined();
    });

    test('document methods should be available', () => {
      expect(typeof document.createElement).toBe('function');
      expect(typeof document.getElementById).toBe('function');
      expect(typeof document.querySelector).toBe('function');
      expect(typeof document.querySelectorAll).toBe('function');
    });

    test('document properties should be available', () => {
      expect(document.head).toBeDefined();
      expect(document.body).toBeDefined();
      expect(typeof document.head).toBe('object');
      expect(typeof document.body).toBe('object');
    });

    test('window.matchMedia should be available', () => {
      expect(typeof window.matchMedia).toBe('function');
      const mediaQuery = window.matchMedia('(max-width: 600px)');
      expect(mediaQuery).toBeDefined();
      expect(typeof mediaQuery.matches).toBe('boolean');
    });

    test('HTMLElement should be available', () => {
      expect(typeof HTMLElement).toBe('function');
      expect(HTMLElement.prototype).toBeDefined();
    });

    test('HTMLStyleElement should be available', () => {
      expect(typeof HTMLStyleElement).toBe('function');
    });

    test('window.location should be available', () => {
      expect(window.location).toBeDefined();
      expect(typeof window.location.protocol).toBe('string');
    });

    test('should be able to create DOM elements', () => {
      const div = document.createElement('div');
      expect(div).toBeInstanceOf(HTMLElement);
      expect(div.tagName).toBe('DIV');

      const style = document.createElement('style');
      expect(style).toBeInstanceOf(HTMLStyleElement);
      expect(style.tagName).toBe('STYLE');
    });

    test('should be able to manipulate DOM', () => {
      const element = document.createElement('div');
      element.id = 'test-element';
      document.body.appendChild(element);

      const found = document.getElementById('test-element');
      expect(found).toBe(element);
      expect(found?.id).toBe('test-element');

      document.body.removeChild(element);
    });
  });

  describe('Platform mocking', () => {
    test('Platform.OS should be "web"', () => {
      expect(Platform.OS).toBe('web');
    });

    test('Platform.select should return web value when available', () => {
      const spec = {
        web: 'web-value',
        ios: 'ios-value',
        android: 'android-value',
      };
      expect(Platform.select(spec)).toBe('web-value');
    });

    test('Platform.select should return default when web value is not available', () => {
      const spec = {
        ios: 'ios-value',
        android: 'android-value',
        default: 'default-value',
      };
      expect(Platform.select(spec)).toBe('default-value');
    });

    test('Platform.select should return undefined when neither web nor default is available', () => {
      const spec = {
        ios: 'ios-value',
        android: 'android-value',
      };
      expect(Platform.select(spec)).toBeUndefined();
    });

    test('Platform.select should handle function spec', () => {
      const spec = {
        web: () => 'web-function',
        ios: () => 'ios-function',
        default: () => 'default-function',
      };
      const result = Platform.select(spec);
      expect(typeof result).toBe('function');
      expect(result()).toBe('web-function');
    });

    test('Platform.select should handle null spec', () => {
      // @ts-expect-error Testing null handling as per jest-web-setup.js
      expect(Platform.select(null)).toBeNull();
    });

    test('Platform.select should handle undefined spec', () => {
      // @ts-expect-error Testing undefined handling as per jest-web-setup.js
      expect(Platform.select(undefined)).toBeUndefined();
    });

    test('Platform.select should handle non-object spec', () => {
      // @ts-expect-error Testing non-standard usage
      expect(Platform.select('string')).toBe('string');
      // @ts-expect-error Testing non-standard usage
      expect(Platform.select(123)).toBe(123);
    });

    test('Platform.select should prioritize web over default', () => {
      const spec = {
        web: 'web-value',
        default: 'default-value',
      };
      expect(Platform.select(spec)).toBe('web-value');
    });
  });

  describe('Component rendering', () => {
    beforeEach(() => {
      jest.useFakeTimers();
    });

    afterEach(() => {
      jest.runOnlyPendingTimers();
      jest.useRealTimers();
    });

    test('should render Animated.View', () => {
      const { container } = render(
        <Animated.View
          testID="animated-view"
          style={{ width: 100, height: 100 }}
        />
      );

      const element = screen.getByTestId('animated-view');
      expect(element).toBeDefined();
      expect(container.firstChild).toBeTruthy();
    });

    test('should render Animated.Text', () => {
      render(<Animated.Text testID="animated-text">Hello World</Animated.Text>);

      const element = screen.getByTestId('animated-text');
      expect(element).toBeDefined();
      // For web, check text content using DOM API instead of jest-native matcher
      expect(element.textContent).toBe('Hello World');
    });

    test('should render component with useAnimatedStyle', () => {
      function AnimatedComponent() {
        const width = useSharedValue(100);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: width.value,
          };
        });

        return <Animated.View testID="animated-view" style={animatedStyle} />;
      }

      render(<AnimatedComponent />);
      const element = screen.getByTestId('animated-view');

      expect(element).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });

    test('should update styles when SharedValue changes', () => {
      function AnimatedComponent() {
        const width = useSharedValue(100);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: width.value,
            height: width.value,
          };
        });

        const handlePress = () => {
          width.value = 200;
        };

        return (
          <View>
            <Animated.View testID="animated-view" style={animatedStyle} />
            <Button testID="button" onPress={handlePress} title="Update" />
          </View>
        );
      }

      render(<AnimatedComponent />);
      const view = screen.getByTestId('animated-view');
      const button = screen.getByTestId('button');

      expect(view).toBeDefined();
      expect(button).toBeDefined();

      // Verify interaction works
      fireEvent.click(button);
      jest.runAllTimers();

      // Note: getAnimatedStyle is not supported for web components
      // We verify the component renders and interactions work
      expect(view).toBeDefined();
    });

    test('should render with withTiming animation', () => {
      function AnimatedComponent() {
        const width = useSharedValue(50);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: withTiming(width.value, { duration: 300 }),
          };
        });

        const handlePress = () => {
          width.value = 150;
        };

        return (
          <View>
            <Animated.View testID="animated-view" style={animatedStyle} />
            <Button testID="button" onPress={handlePress} title="Animate" />
          </View>
        );
      }

      render(<AnimatedComponent />);
      const view = screen.getByTestId('animated-view');
      const button = screen.getByTestId('button');

      expect(view).toBeDefined();
      expect(button).toBeDefined();

      // Verify animation interaction works
      fireEvent.click(button);
      jest.advanceTimersByTime(150);
      expect(view).toBeDefined();

      jest.advanceTimersByTime(200);
      expect(view).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });

    test('should render custom animated component', () => {
      interface CustomViewProps {
        children?: React.ReactNode;
        testID?: string;
      }

      const CustomView = ({ children, testID, ...props }: CustomViewProps) => (
        <View testID={testID} {...props}>
          {children}
        </View>
      );

      const AnimatedCustomView = createAnimatedComponent(CustomView);

      // @ts-expect-error - createAnimatedComponent type inference doesn't always preserve testID
      // but it works at runtime since AnimatedComponentProps preserves component props
      render(
        <AnimatedCustomView testID="custom-view">
          <Text>Custom Content</Text>
        </AnimatedCustomView>
      );

      const element = screen.getByTestId('custom-view');
      expect(element).toBeDefined();
      expect(screen.getByText('Custom Content')).toBeDefined();
    });

    test('should handle multiple animated styles', () => {
      function AnimatedComponent() {
        const width = useSharedValue(100);
        const opacity = useSharedValue(0.5);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: width.value,
            opacity: opacity.value,
          };
        });

        const handlePress = () => {
          width.value = 200;
          opacity.value = 1;
        };

        return (
          <View>
            <Animated.View testID="animated-view" style={animatedStyle} />
            <Button testID="button" onPress={handlePress} title="Update" />
          </View>
        );
      }

      render(<AnimatedComponent />);
      const view = screen.getByTestId('animated-view');
      const button = screen.getByTestId('button');

      expect(view).toBeDefined();
      expect(button).toBeDefined();

      // Verify interaction works
      fireEvent.click(button);
      jest.runAllTimers();

      // Note: getAnimatedStyle is not supported for web components
      expect(view).toBeDefined();
    });

    test('should combine animated and static styles', () => {
      function AnimatedComponent() {
        const width = useSharedValue(100);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: width.value,
          };
        });

        return (
          <Animated.View
            testID="animated-view"
            style={[animatedStyle, { height: 200, backgroundColor: 'red' }]}
          />
        );
      }

      render(<AnimatedComponent />);
      const view = screen.getByTestId('animated-view');

      expect(view).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });

    test('should handle Pressable with animated styles', () => {
      function AnimatedPressableComponent() {
        const scale = useSharedValue(1);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            transform: [{ scale: scale.value }],
          };
        });

        const handlePressIn = () => {
          scale.value = 0.95;
        };

        const handlePressOut = () => {
          scale.value = 1;
        };

        return (
          <Animated.View
            testID="pressable-view"
            style={animatedStyle}
            onTouchStart={handlePressIn}
            onTouchEnd={handlePressOut}>
            <Text>Press me</Text>
          </Animated.View>
        );
      }

      render(<AnimatedPressableComponent />);
      const view = screen.getByTestId('pressable-view');

      expect(view).toBeDefined();
      expect(screen.getByText('Press me')).toBeDefined();

      // Verify mouse events work
      fireEvent.mouseDown(view);
      jest.runAllTimers();
      expect(view).toBeDefined();

      fireEvent.mouseUp(view);
      jest.runAllTimers();
      expect(view).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });

    test('should render nested animated components', () => {
      function NestedAnimatedComponent() {
        const outerWidth = useSharedValue(200);
        const innerWidth = useSharedValue(100);

        const outerStyle = useAnimatedStyle(() => ({
          width: outerWidth.value,
          height: outerWidth.value,
        }));

        const innerStyle = useAnimatedStyle(() => ({
          width: innerWidth.value,
          height: innerWidth.value,
        }));

        return (
          <Animated.View testID="outer-view" style={outerStyle}>
            <Animated.View testID="inner-view" style={innerStyle} />
          </Animated.View>
        );
      }

      render(<NestedAnimatedComponent />);
      const outerView = screen.getByTestId('outer-view');
      const innerView = screen.getByTestId('inner-view');

      expect(outerView).toBeDefined();
      expect(innerView).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });

    test('should handle conditional rendering with animated styles', () => {
      function ConditionalAnimatedComponent({ show }: { show: boolean }) {
        const width = useSharedValue(100);

        const animatedStyle = useAnimatedStyle(() => {
          return {
            width: width.value,
            display: show ? 'flex' : 'none',
          };
        });

        return <Animated.View testID="animated-view" style={animatedStyle} />;
      }

      const { rerender } = render(<ConditionalAnimatedComponent show={true} />);
      let view = screen.getByTestId('animated-view');
      expect(view).toBeDefined();

      rerender(<ConditionalAnimatedComponent show={false} />);
      view = screen.getByTestId('animated-view');
      expect(view).toBeDefined();
      // Note: getAnimatedStyle is not supported for web components
    });
  });
});

@MatiPl01 MatiPl01 self-assigned this Dec 6, 2025
@MatiPl01 MatiPl01 force-pushed the @matipl01/web-jsdom-for-web-jest-tests branch from 11e4861 to 91fd999 Compare December 6, 2025 15:34
@MatiPl01 MatiPl01 changed the title Attempt to add correct web unit testing environment feat: Web-specific jest tests support Dec 6, 2025
@MatiPl01 MatiPl01 marked this pull request as ready for review December 6, 2025 16:07
@MatiPl01 MatiPl01 requested a review from tjzel December 6, 2025 16:30
Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, on the second thought the extensions of the files should be .test.web.ts, not .web.test.ts to make it compliant with TypeScript resolutions #8371

Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@MatiPl01
Copy link
Member Author

MatiPl01 commented Dec 8, 2025

I prefer .web.test.ts because the editor properly recognizes them as test files and puts a correct icon near the file name:

Screenshot 2025-12-08 at 12 48 05

@tjzel
Copy link
Collaborator

tjzel commented Dec 8, 2025

You can configure your editor to pickup test.web.ts as a test file icon.

@MatiPl01 MatiPl01 force-pushed the @matipl01/web-jsdom-for-web-jest-tests branch from 7b75a58 to 6ab6ed7 Compare December 9, 2025 16:05
@MatiPl01 MatiPl01 added this pull request to the merge queue Dec 9, 2025
Merged via the queue into main with commit f36963c Dec 9, 2025
13 checks passed
@MatiPl01 MatiPl01 deleted the @matipl01/web-jsdom-for-web-jest-tests branch December 9, 2025 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants