routify icon indicating copy to clipboard operation
routify copied to clipboard

[jest] Add an example of testing using jest

Open onyxcherry opened this issue 3 years ago • 6 comments

Hello, could you add some example of testing a component that uses Routify (or its part like url)?

This would improve our experience and avoid struggling with configuration in the future.

For example, I built my project from scratch using npx @roxi/routify@next create my-r3-app, then installed necessary packages (npm i --save-dev jest babel-jest svelte-jester @testing-library/svelte babel). However, still encounter an error - referring to ESM support - like:

ReferenceError: module is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension 
and '/home/.../svelte/my-r3-app/package.json' contains "type": "module". 
To treat it as a CommonJS script, rename it to use the '.cjs' file extension.

version 3. But also related to the 2

files babel.config.js:

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current"
        }
      }
    ]
  ],
  plugins: ["@babel/plugin-transform-modules-commonjs"]
};

jest.config.js:

module.exports = {
  transform: {
    "^.+\\.svelte$": [
      "svelte-jester",
      {
        "preprocess": true
      }
    ],
    "^.+\\.ts$": "ts-jest",
    "^.+\\.js$": "babel-jest"
  },
  moduleFileExtensions: [
    "js",
    "ts",
    "svelte"
  ]
};

Note: see discussion at the Discord #general channel.

onyxcherry avatar Jan 01 '22 19:01 onyxcherry

+1 this would be great!

Currently, I'm just mocking routify and overwriting the properties that I need, but that's not ideal if I want to render a component that relies on the functionality of e.g. url()

jest.mock('@roxi/routify', () => {
  const originalModule = jest.requireActual('@roxi/routify');

  return {
      ...originalModule.default,
      params: writable({}),
  };
});

tobiasbueschel avatar May 27 '22 03:05 tobiasbueschel

I would also benefit greatly from seeing some examples for using (and especially mocking with) Jest. I have not been able to find any examples online of people mocking the likes of url and isActive.

This post on StackOverflow demonstrates a good effort to mock url from @roxi/routify with Jest, but it seems like he/she never resolved his/her issues.

Edit: I also cannot seem to use @tobiasbueschel's mocking approach above - I run into

ReferenceError: Cannot access 'store_1' before initialization

due to the writable({}) call. I saw this was one of the errors that the StackOverflow person encountered, too, so if there is a common fix/workaround for this, that information would be quite valuable I think.

cooperwalbrun avatar Nov 18 '22 05:11 cooperwalbrun

The helpers are objects with a subscribe method, which Svelte calls on component initialization. Most of them rely on the context of the parent user component, so the logic ends up like this

import { getContext } from 'svelte'
import { derived } from 'svAelte/store'

export const someHelper = {
  subscribe: () => {
    // we need the context and we can only get this at component initialization
    // luckily Svelte will handle this, since the presence of `$someHelper` in a component
    // will cause Svelte to call subscribe at initialization
    const { someStore } = getContext('routify-fragment-context')

    // return the helper function, eg. url or isActive
    return createSomeHelperFunctionFromContext(someStore)
  }
}}

// if the helper has to be reactive, derived is used
export const someHelper2 = {
  subscribe: () => {
    const { someStore } = getContext('routify-fragment-context')
    return derived(someStore, $someStore => {
      return createSomeHelperFunctionFromContext(someStore)
    })
  }
}}

The actual helpers for R3 can be seen here. https://github.com/roxiness/routify/blob/next/lib/runtime/helpers/index.js

I wish I could help with Jest, but I dislike it so much I ended up writing my own test framework.

jakobrosenberg avatar Nov 19 '22 10:11 jakobrosenberg

Alright, I did some tinkering with mocking in Jest and I managed to come up with a solution that allows for a little more flexibility in mocked values (thanks @jakobrosenberg for pointing me to your custom framework; it helped guide me to the answer).

Below demonstrates how to mock specific Routify helper functions using Jest such that rendering components which depend on them causes no errors. @jakobrosenberg I am not sure whether you would want to include this example somewhere in the Routify documentation?

import type { Readable, Unsubscriber } from 'svelte/store';
import type { IsActiveHelper, UrlHelper } from '@roxi/routify/typings/runtime/helpers';
import { derived, readable } from 'svelte/store';

const mockIsActive: IsActiveHelper = path => true; // Customize this based on your unit test
const mockUrl: UrlHelper = path => path; // Customize this based on your unit test

function mockAggregator<T>(value: T, setter: (value: T) => void): void | Unsubscriber {
  // Do nothing; there is no reason to aggregate value changes because we hard-code the returned
  // values in mockIsActive and mockUrl above
}

jest.mock('@roxi/routify', () => {
  // These mock store definitions must be defined here (instead of e.g. global scope) because Jest
  // will automatically hoist the jest.mock() call to the top of the file, and if isActiveStore or
  // urlStore were defined in global scope, they would be undefined at the point in time this mock
  // is being invoked.
  // See: https://www.bam.tech/article/fix-jest-mock-cannot-access-before-initialization-error
  const isActiveStore: Readable<IsActiveHelper> = {
    subscribe: (run, invalidate) =>
      derived(readable<IsActiveHelper>(), mockAggregator, mockIsActive).subscribe(run, invalidate)
  };
  const urlStore: Readable<UrlHelper> = {
    subscribe: (run, invalidate) =>
      derived(readable<UrlHelper>(), mockAggregator, mockUrl).subscribe(run, invalidate)
  };

  return {
    // Add other Routify mocks here as needed
    isActive: isActiveStore,
    url: urlStore
  };
});

test('my test', () => {
   // Render your Svelte component(s) as usual
});

Additional Findings

Interestingly, when I tried doing this:

derived(readable(mockIsActive), mockAggregator).subscribe(run, invalidate)

I received internal Svelte errors in my tests. I had expected this derived usage to work the same as

derived(readable(), mockAggregator, mockIsActive).subscribe(run, invalidate)

because the API documentation suggests that these are simply different ways to express the same thing, but that does not appear to be the case. Anyway, this is not an issue in practice because the latter approach works fine - I just wanted to call it out here for awareness.

cooperwalbrun avatar Nov 20 '22 19:11 cooperwalbrun

Thanks for the solution @cooperwalbrun . I might include a link to your post. 🙂

jakobrosenberg avatar Nov 27 '22 10:11 jakobrosenberg

I recently revisited this and came up with an alternative way to make the Routify context available to components during Jest unit tests (i.e. so helper functions like $url and $isActive work properly). This code is simpler than what I proposed above, but it still has the drawback of requiring the user to understand a bit about Routify's internal logic. Oh well. I will post it below in case it helps others.

Note: this is a minimal "get it working for my use case" implementation. The mocked store would likely have to be expanded to include other key/value pairs depending on the Routify functions that users' components depend on. My personal use case is strictly $url and $isActive.

Helper function:

import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { route } from '@roxi/routify/runtime/store';

type ArbitraryMap = { [key: string]: any };

export function createRoutifyContext(
  activePath: string,
  activeParams: ArbitraryMap = {},
  activeComponentContext: ArbitraryMap = { params: {} } // This is the default from Routify's "rootContext" constant
): Writable<any> {
  const activeRoute = { path: activePath, params: activeParams };

  // We have to update the global constant "route" because Routify's internal logic is not always
  // strictly combinatorial; in a couple places, it references the context directly from global
  // scope via an import. In other places, it will pull the context in via getRoutifyContext(),
  // which will instead point at the object returned by this createRoutifyContext() function
  // thanks to our usage of the "routify" context key when we render our components in unit tests.
  // Because we cannot control which of these two places gets referenced by Routify internally, we
  // set the active route's mocked data in both places - in the faked root context which gets
  // passed around within Routify AND in the global "route" constant.
  route.set(activeRoute);

  // The structure of the return value below is based on the "rootContext" global constant
  // that is defined here: https://github.com/roxiness/routify/blob/master/runtime/store.js.
  // There are also additional fields that are expected which are not added during initialization -
  // in these cases, we have added them by hand.
  return writable({
    component: activeComponentContext,
    route: activeRoute,
    routes: []
  });
}

Using the helper function in a test:

test('some test', () => {
  const routifyContext = createRoutifyContext('/my/path');
  const dom = render(MyComponent, {
    target: document.body,
    // The "routify" key used below is based on what Routify itself uses, i.e. in the
    // getRoutifyContext() function in @roxi/routify/runtime/helpers, the context name being used
    // is "routify"
    context: new Map([['routify', routifyContext]])
  });
  // ...perform assertions related to your component's behavior in the context of /my/path...
});

cooperwalbrun avatar Feb 18 '23 20:02 cooperwalbrun