かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript Object 型を判定したい

any 型で入ってきたデータのプロパティにアクセスしようとすると ESLint に怒られるので object 型だと判定したかった

value.foo

=> Unsafe member access .foo on an `any` value. eslint@typescript-eslint/no-unsafe-member-access

JavaScript の Object

オブジェクトを除くすべての型は、言語の最下層で直接表現される不変値を定義しています。これらの型の値を プリミティブ値 と呼びます。
cf. JavaScript のデータ型とデータ構造 - JavaScript | MDN

JavaScript のプリミティブ型
typeof の返値
Null型 null "object"
Undefined型 undefined "undefined"
論理型 boolean "boolean"
数値型 number "number"
長整数型 bigint "bigint"
文字列型 string "string"
シンボル型 symbol "symbol"

プリミティブ型以外は Object なので、Array も Function も Date も RegExp も Object という扱い

isObject 関数を作成する

Type Guard関数をユーザーが定義することができます。これは、someArgumentName is SomeTypeを返す関数です。
cf. 型ガード - TypeScript Deep Dive 日本語版

返り値が T is object な Type Guard 関数を作成する

export const isObject(value: unknown): value is object => {
  const type = typeof value;
  return value !== null && (type == 'object' || type == 'function');
}

isObject (Type Guard関数) が true になる Object 型の変数のプロパティにアクセスできない問題

import { isObject } from './isObject';
if (isObject(myObj)) {
  myObj.foo; // ここで TypeError
}

=> Property 'foo' does not exist on type 'object'. ts(2339)
object 型だが foo プロパティがあるとは限らないので TypeError になっている

Type Guard の型を {[key: string]: unknown}Record<string, unknown> にすれば良い

export const isObject(value: unknown): value is Record<string, unknown> => {
  const type = typeof value;
  return value !== null && (type == 'object' || type == 'function');
}

👇 これで Object のプロパティへのアクセスが OK になる

import { isObject } from './isObject';
if (isObject(myObj)) {
  myObj.foo; // OK
}

{key: value} なオブジェクトに限定したい場合は constructor を使うと判別できる

JavaScript の Object は Array や function, Date なども含んでいて . でプロパティにアクセスできるから問題は無いと思うが Type Guard の value is Record<string, unknown> は厳密ではないよな〜って感じなので {key: value} なオブジェクトで判定したい場合

export const isObject(value: unknown): value is Record<string, unknown> => {
  return value !== null && typeof value === 'object' && value.constructor === Object;
}

cf. JavaScript `{}` なオブジェクトだけを判定したい - かもメモ

TypeScript化した

おわり


[参考]

Jest × React Testing Library テスト内のイベントが state の更新を待ってくれないにハマる

Jest × React Testing Library の勉強をしています。
今回は state が更新されるかをテストしていて、テスト内の useEvent.click 直後の expect が state が更新される前の値となってしまいテストが落ちてしまったメモです

環境
  • jest 26.6.3
  • @testing-library/dom 7.29.2
  • @testing-library/jest-dom 5.17.0
  • @testing-library/react 14.0.0
  • @testing-library/user-event 14.4.3
  • React 18.2.0
  • TypeScript 5.1.6

React Component

構成としてはシンプルに Context に持たせた state (toggleFlg) がコンポーネントのボタンを押すと変更されるというもの

/src/StateProvider.tsx

interface IContext {
  toggleFlg: boolean;
  setToggleFlg: React.Dispatch<React.SetStateAction<boolean>>;
}

type StateProviderProps = {
  children: ReactNode;
};
export const StateProvider: FC<StateProviderProps> = ({ children }) => {
  const [toggleFlg, setToggleFlg] = useState<boolean>(false);
  return (
    <StateContext.Provider value={{ toggleFlg, setToggleFlg }}>
      {children}
    </StateContext.Provider>
  );
};

export const useStateContext = () => useContext(StateContext);

/src/ChangeToggleFlgButton.tsx

import { useStateContext } from '@/StateProvider';
export const ChangeToggleFlgButton: FC = () => {
  const { setToggleFlg } = useStateContext();
  return (
    <button type="button" onClick={() => {setToggleFlg((flg) => !flg)}}>
      change
    </button>
  );
};

/src/Content.tsx

import { useStateContext } from '@/StateProvider';
export const Content:FC = () => {
  const { toggleFlg } = useStateContext();
  return (
    <div>
      <p>Context</p>
      <code data-testid='toggleFlg'>{toggleFlg ? 'true' : 'false'}</code>
    </div>
  );
};

useEvent.click 直後の expect 内で state がまだ更新されてない状態になる

ChangeToggleFlgButton をクリックしたら state が更新され ContexttoggleFlg の値が変更されることをテストしたい

/src/__test__/Context.test.tsx;

import '@testing-library/jest-dom/extend-expect';
import useEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { Content } from '@/Content';
import { ChangeToggleFlgButton } from '@/ChangeToggleFlgButton';
import { StateProvider } from '@/StateProvider';

describe('StateProvider を使った state の変更テスト', () => {
  it('ChangeToggleFlgButton をクリックしたら toggleFlg の値が変更されること', () => {
    render(
      <StateProvider>
        <ChangeToggleFlgButton />
        <Context />
      </StateProvider>,
    );
    // render 直後 toggleFlg は false
    expect(screen.getByTestId('toggleFlg').textContent).toBe('false');
    // ChangeToggleFlgButton をクリック
    useEvent.click(screen.getByRole('button'));
    // クリック後 toggleFlg は true
    expect(screen.getByTestId('toggleFlg').textContent).toBe('true');
  });
});

👇 テストが落ちる

FAIL  src/__tests__/Context.test.tsx
  StateProvider を使った state の変更テスト
    ✕ ChangeToggleFlgButton をクリックしたら toggleFlg の値が変更されること (23 ms)
  expect(received).toBe(expected) // Object.is equality
    Expected: "true"
    Received: "false"
    > 21 |     expect(screen.getByTestId('toggleFlg').textContent).toBe('true');

toggleFlg の値が "true" を期待しているが "false" なのでテストが落ちる
つまり、クリックが実行されて state が更新される前に DOM から値を取っている状態っぽい

解決方法: waitForuseEvent.click を囲って state が更新されるのを待たせる

/src/__test__/Context.test.tsx;

import '@testing-library/jest-dom/extend-expect';
import useEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
// 略
describe('StateProvider を使った state の変更テスト', async () => {
  it('ChangeToggleFlgButton をクリックしたら toggleFlg の値が変更されること', () => {
    render(
      <StateProvider>
        <ChangeToggleFlgButton />
        <Context />
      </StateProvider>,
    );
    // render 直後 toggleFlg は false
    expect(screen.getByTestId('toggleFlg').textContent).toBe('false');
    // waitFor を使って state が更新されるのを待つ
    await waitFor(async () => {
      // ChangeToggleFlgButton をクリック
      await useEvent.click(screen.getByRole('button'));
    });
    // クリック後 toggleFlg は true
    expect(screen.getByTestId('toggleFlg').textContent).toBe('true');
  });
});

👇 テスト

 PASS  src/__tests__/Context.test.tsx
  StateProvider を使った state の変更テスト
    ✓ ChangeToggleFlgButton をクリックしたら toggleFlg の値が変更されること (46 ms)

 

バージョンが新しいの使えばいいとか GitHub でも色々議論されているようだが、テスト周りの知見がなさすぎて何が正しいのかイマイチ把握できていない。
今はとりあえず useEvent 後の expect 内で state の更新が待たれない場合は waitFor を使ってみると良いのかな。くらいに思っておきます。


[参考]

Jest テスト内で axios のインポートが出来ないエラーにハマる

Jest を使ったテストをしていて axios を使っているコンポーネントのテストで下記のようなエラーになった

SyntaxError: Cannot use import statement outside a module
    > 1 | import axios from 'axios';
        | ^

SyntaxError: Cannot use import statement outside a module

axios が import できないっぽい。なんでや!

環境
  • jest 26.6.3
  • axios 1.4.0

axios が ESM で提供されていて、Jest が CommonJS で実行されるためにエラーが発生している

For context: Problem seem to be that Axios is now built as ES Module instead of CommonJs when not run in Node. Problem with Jest is that it runs code in Node, but application is built for web-clients. This is why telling Jest to transform Axios works.
cf. Jest tests failed after upgrading axios to v1.1.2 · Issue #5101 · axios/axios · GitHub

jest は node_module かのファイルを変換しないので ESM の axios の import/export が上手く扱えずエラーになっているっぽ

Basically, jest runs on Node.js environment, so it uses modules following the CommonJS. If you want to use axios up to 1.x.x, you have to transpile the JavaScript module from ECMAScript type to CommonJS type. Jest ignores /node_modules/ directory to transform basically.
cf. node.js - "Cannot use import statement outside a module" with Axios - Stack Overflow

axios 内にある CommonJS でビルドされたパスを Jest に設定する事で解決できる

node_modules にインストールされた axios 内には CommonJS にビルドされたものがあるので、そのパスを moduleNameMapper で Jest に渡してあげれば良い

package.json

"jest": {
  "moduleNameMapper": {
    "^axios$": "axios/dist/node/axios.cjs"
  }
}

jest.config.ts を作成している場合は require.resolve('axios') でもいけるらしい

moduleNameMapper: {
  '^axios$': require.resolve('axios'),
},

cf. Jest tests failed after upgrading axios to v1.1.2 · Issue #5101 · axios/axios · GitHub


[参考]