SolidJSのテスト環境構築

https://docs.solidjs.com/guides/testing#rendering-the-component を読めばいいだけなんですが、Router周りにバグあったのでその回避方法と、その他いくつか解説していきます。

## 前提

- "@solidjs/router": "^0.15.0"
- "@solidjs/start": "^1.0.10"
- "solid-js": "^1.9.3”

## テスト環境構築

- 前提ライブラリインストール
    - `npm i -D vitest jsdom @solidjs/testing-library @testing-library/user-event @testing-library/jest-dom`
- package.jsonに追記
    
    ```tsx:package.json
     "scripts": {
        "test": "vitest"
      }
    ```
    
- tsconfig.jsonに追記
    
    ```tsx:tsconfig.json
      "compilerOptions": {
        // ...
        "jsx": "preserve",
        "jsxImportSource": "solid-js",
        "types": ["vite/client", "@testing-library/jest-dom"]
      }
    ```
    
- **solidstartを使っている場合**はvitest.config.tsを作成する
    
    ```tsx:vitest.config.ts
    import solid from "vite-plugin-solid"
    import { defineConfig } from "vitest/config"
    
    export default defineConfig({
      plugins: [solid()],
      resolve: {
        conditions: ["development", "browser"],
      },
    })
    ```
    
- テストを書く
    
    ```tsx:Counter.spec.tsx
    import { test, expect } from "vitest"
    import { render, screen } from "@solidjs/testing-library"
    import userEvent from "@testing-library/user-event"
    import { Counter } from "./Counter"
    
    test("increments value", async () => {
      render(() => <Counter />)
      const counter = screen.getByRole('button')
      expect(counter).toHaveTextContent("1")
      await userEvent.click(counter)
      expect(counter).toHaveTextContent("2")
    })
    ```
    

## Router(やそのほかProvider)を絡めたテスト

**@solidjs/testing-libraryの`render`  関数はlocationを取れるので以前のバージョンなら何もしなくてもRouterが絡むテストができたのですが、[issue](https://github.com/solidjs/vite-plugin-solid/issues/157)を見るとどうやらできなくなってるようなので** 、Routerが絡むテストが書けるよう `renderApp` 関数を実装します。

テスト対象のコンポーネントは次のような Routerが絡まったコンポーネントです。

```tsx
import { useParams, useSearchParams } from "@solidjs/router";

export default function Page() {
  const params = useParams();
  const [search, setSearch] = useSearchParams<{ query: string }>();

  return (
    <div>
      <div>param: {params.id}</div>
      <div>search: {search.query}</div>
      <button
        type="button"
        onClick={() => {
          setSearch({ query: search.query === "foo" ? "bar" : "foo" });
        }}
      >
        Change Search
      </button>
    </div>
  );
}

```

これが次のようにルーティングされてる想定です。

```tsx
<Route path="/router/:id" component={Page} />
```

これを次のようにテストしたいです。

```tsx
import { screen } from "@solidjs/testing-library";
import userEvent from "@testing-library/user-event";
import Page from "~/routes/router/[id]";
import { renderApp } from "~/test/renderApp";

test("increments value", async () => {
  renderApp(() => <Page />, {
    path: "/router/:id",
    currentPath: "/router/1?query=foo",
  });
  expect(screen.getByText("param: 1")).toBeInTheDocument();
  expect(screen.getByText("search: foo")).toBeInTheDocument();
  const button = screen.getByText("Change Search");
  await userEvent.click(button);
  expect(screen.getByText("search: bar")).toBeInTheDocument();
  await userEvent.click(button);
  expect(screen.getByText("search: foo")).toBeInTheDocument();
});

```

このときrenderApp関数は次のように実装します。

```tsx
import { MetaProvider } from "@solidjs/meta";
import {
  MemoryRouter,
  Route,
  Router,
  createMemoryHistory,
} from "@solidjs/router";
import { render } from "@solidjs/testing-library";
import type { ParentProps } from "solid-js";
import { args } from "valibot";

type UI = Parameters<typeof render>[0];
type Options = NotNull<Parameters<typeof render>[1]>;
type NotNull<T> = T extends null | undefined ? never : T;
export const renderApp = (
  ui: UI,
  options?: Omit<Options, "location"> & { path?: string; currentPath?: string },
) => {
  const Wrapper =
    options?.wrapper ??
    ((props: ParentProps) => {
      return <>{props.children}</>;
    });
  const history = createMemoryHistory();
  options?.currentPath &&
    history.set({ value: options.currentPath, scroll: false, replace: true });
  return render(ui, {
    ...options,
    wrapper: (props) => {
      return (
        <MetaProvider>
          <MemoryRouter history={history}>
            <Route
              path={options?.path ?? ""}
              component={() => <Wrapper>{props.children}</Wrapper>}
            />
          </MemoryRouter>
        </MetaProvider>
      );
    },
  });
};

```

## fast-checkを使う

前提ライブラリインストール

`npm i -D @fast-check/vitest` 

普通に使えました

```tsx
import { fc, test } from "@fast-check/vitest";
import userEvent from "@testing-library/user-event";
import { renderApp } from "~/test/renderApp";
import Counter from "./Counter";

test.prop([fc.integer({ min: 1, max: 10 })], { numRuns: 25 })(
  "increments value",
  async (count) => {
    const { getByRole } = renderApp(() => <Counter />);
    const counter = getByRole("button");
    expect(counter).toHaveTextContent("Clicks: 0");
    for (let i = 0; i < count; i++) {
      await userEvent.click(counter);
    }
    expect(counter).toHaveTextContent(`Clicks: ${count}`);
  },
  10000,
);

```

## mswを使う

前提ライブラリインストール

`npm i -D msw` 

```tsx:vitest.config.ts
import path from "node:path";
import solid from "vite-plugin-solid";
import { defineConfig } from "vitest/config";

export default defineConfig({
  ...
  test: {
    ...
    setupFiles: ["src/test/setup.ts"],
  },
});

```

```tsx:src/test/msw.ts
import { setupServer } from "msw/node";

export const server = setupServer();
```

```tsx:src/test/setup.ts
import { server } from "./msw";

beforeAll(() => {
  server.listen();
});
afterEach(async () => {
  server.resetHandlers();
});
afterAll(() => {
  server.close();
});

```

テストは次の形

```tsx
import { screen, waitFor } from "@solidjs/testing-library";
import { http, HttpResponse } from "msw";
import Page, { route } from "~/routes";
import { server } from "~/test/msw";
import { renderApp } from "~/test/renderApp";

function setupMSW() {
  server.use(
    http.all("https://jsonplaceholder.typicode.com/todos/1", () => {
      return HttpResponse.json({
        userId: 1,
        id: 1,
        title: "todo1",
        completed: false,
      });
    }),
  );
}

beforeEach(() => {
  setupMSW();
});

it("increments value", async () => {
  await route.preload();
  renderApp(() => <Page />, {});
  await waitFor(() => expect(screen.getByText("todo1")).toBeInTheDocument());
});

```