DEV Community

Mike Schutte
Mike Schutte

Posted on

Mocking framer-motion v4

Testing Library has changed the UI testing game for the better. If you haven't tried it yet, check it out.

At work our new UI efforts are powered by Chakra UI, which uses Framer Motion under the hood for animations. With all this work, we're testing all of it using Jest and React Testing Library (RTL).

One great way to defend against UI regressions (copy, styles, etc) is snapshot testing. As we're getting more and more into Chakra's features and better tests, we've run into issues where the animated style properties have minute differences between snapshots.

RTL recommends mocking animation libraries to solve this problem. There are a few solutions for doing this with framer-motion on the web, but I don't think they are up to snuff for the current version of the library (4._).

After digging around the framer motion source, I realized our attempt at mocking the motion export as an object (see here) wasn't working because motion is constructed using Proxy.

Enough with the words, how do I stabilize my snapshot tests?!

// __mocks__/framer-motion.ts

import { CustomDomComponent, CustomMotionComponentConfig } from 'framer-motion/types/render/dom/motion-proxy';
import * as React from 'react';

const actual = jest.requireActual('framer-motion');

// https://github.com/framer/motion/blob/main/src/render/dom/motion.ts
function custom<Props>(
  Component: string | React.ComponentType<Props>,
  _customMotionComponentConfig: CustomMotionComponentConfig = {},
): CustomDomComponent<Props> {
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  return React.forwardRef((props, ref) => {
    const regularProps = Object.fromEntries(
      // do not pass framer props to DOM element
      Object.entries(props).filter(([key]) => !actual.isValidMotionProp(key)),
    );
    return typeof Component === 'string' ? (
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      <div ref={ref} {...regularProps} />
    ) : (
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      <Component ref={ref} {...regularProps} />
    );
  });
}

const componentCache = new Map<string, unknown>();
const motion = new Proxy(custom, {
  get: (_target, key: string) => {
    if (!componentCache.has(key)) {
      componentCache.set(key, custom(key));
    }

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return componentCache.get(key)!;
  },
});

module.exports = {
  __esModule: true,
  ...actual,
  AnimatePresence: ({ children }: { children: React.ReactChildren }) => <>{children}</>,
  motion,
};
Enter fullscreen mode Exit fullscreen mode

Now in your test setup file you can call jest.mock('framer-motion') and all the animation related properties will be filtered out.

Happy testing!

Top comments (10)

Collapse
 
stefania profile image
Stefania

"CustomMotionComponentConfig" is not exported anymore in version 6.3.13

Collapse
 
jasongerbes profile image
Jason Gerbes

Hey @tmikeschu, thanks for sharing this mock.

I noticed an issue with this logic:

return typeof Component === 'string' ? (
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    <div ref={ref} {...regularProps} />
) : (
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    <Component ref={ref} {...regularProps} />
);
Enter fullscreen mode Exit fullscreen mode

By returning a <div> for all string component types, it means that all motion elements (e.g. motion.div, motion.li, motion.ul, etc.) will incorrectly render as a <div>.

Instead, I believe this is all that is needed:

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return <Component ref={ref} {...regularProps} />;
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tmikeschu profile image
Mike Schutte

@jasongerbes thanks for taking a look! Do the motion elements really resolve to a string? I wouldn't expect motion.div to be a string component. I specifically don't want to render motion elements as motion elements because of their animation properties. Let me know if I'm misunderstanding. Thanks!

Collapse
 
jasongerbes profile image
Jason Gerbes

The motion function from framer-motion has two use cases:

  1. Wrapping every standard HTML and SVG element (e.g. motion.div, motion.ul, motion.li, etc).
  2. Wrapping custom components (e.g. const MotionComponent = motion(Component)).

You'll notice that the type of motion includes & HTMLMotionComponents & SVGMotionComponents, which means that motion can either be used as a function (for wrapping custom components), or as an object where the properties are all of the HTML and SVG element tags (e.g. motion.h6).

I expect Framer Motion uses the Proxy for standard HTML and SVG elements as an optimisation, so that the wrapped elements are only created when necessary (e.g. you may not need motion.h6 in your application).

Ultimately though, the expected rendered output of HTML and SVG motion elements should be:

  • <motion.div> -> <div>
  • <motion.ul> -> <ul>
  • <motion.li> -> <li>
  • <motion.h6> -> <h6>
  • ...

If you try snapshotting a few non-<div> elements with your current custom function, you'll notice that they all render as <div>. This is due to the typeof Component === 'string' check, which returns <div> for all HTML and SVG element string values.

Here's my full solution, which includes a as typeof custom & DOMMotionComponents cast on the motion proxy, as per the actual motion proxy:

/* eslint-disable react/display-name */
import { CustomDomComponent } from 'framer-motion/types/render/dom/motion-proxy';
import { DOMMotionComponents } from 'framer-motion/types/render/dom/types';
import React from 'react';

const actual = jest.requireActual('framer-motion');

// https://github.com/framer/motion/blob/main/src/render/dom/motion.ts
function custom<Props>(Component: string | React.ComponentType<Props>): CustomDomComponent<Props> {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    return React.forwardRef((props, ref) => {
        // do not pass framer props to DOM element
        const regularProps = Object.entries(props).reduce((acc, [key, value]) => {
            if (!actual.isValidMotionProp(key)) acc[key] = value;
            return acc;
        }, {});

        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        return <Component ref={ref} {...regularProps} />;
    });
}

const componentCache = new Map<string, any>();
const motion = new Proxy(custom, {
    get: (_target, key: string) => {
        if (!componentCache.has(key)) {
            componentCache.set(key, custom(key));
        }

        return componentCache.get(key)!;
    }
}) as typeof custom & DOMMotionComponents;

module.exports = {
    ...actual,
    AnimatePresence: ({ children }: { children: React.ReactChildren }) => <>{children}</>,
    motion
};

Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
byron121 profile image
byron121

I tried the above solution and overwriting AnimatePresence results in this error:

Error: Uncaught [TypeError: Cannot read property 'jsxDEV' of undefined]

I did some digging around and it looks like jsxDEV is from React. Any suggestions on how to resolve this?

Thread Thread
 
tmikeschu profile image
Mike Schutte

Weird! Sorry nothing off the top of my head. I'll post here if I find anything.

Thread Thread
 
neldeles profile image
neldeles

Thanks for this article and thanks Jason as well for the clarification! Indeed my tests started failing because motion elements were being mocked as divs.

This is working for me with Framer Motion 5.6. Maybe one day I'll be as smart as you guys and be able to come up with a mock like this. Will have to copy-paste this for now. πŸ˜…

Collapse
 
jayantbh profile image
Jayant Bhawal

Somehow removing even a single eslint or ts-ignore comment breaks my tests, but leaving it as is, works. What's going on?

/home/username/path/to/project/__mocks__/framer-motion.tsx:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){import * as React from "react";
                                                                                      ^^^^^^

    SyntaxError: Cannot use import statement outside a module

    > 1 | import { AnimatePresence, motion, MotionProps } from "framer-motion";
        | ^
      2 | import React, { FC, useContext } from "react";
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tmikeschu profile image
Mike Schutte

That is indeed strange. Which comment do you removes that produces this error?

Collapse
 
jayantbh profile image
Jayant Bhawal

Ah, sorry about having commented and basically causing a bit of spam.

Turns out I was facing that issue due to something completely unrelated. No idea why removing comments was causing the issue, but, it is what it is.

Removing any ts-ignore or eslint comment caused the issue.

My fix was to use "module": "commonjs" instead of "esnext" in my tsconfig.test.json.