Closed31

テストコードを書けるようになる

やじはむやじはむ

目標

  1. C#でテストコードの書き方を学び、業務に取り入れられそうなレベルまで学ぶ
  2. フロントエンドでも、個人開発などでテストコードが書けるレベルまで学ぶ

計画

1について

  • xUnit.netのハンズオンコンテンツを進める

https://github.com/csharp-tokyo/xUnit-Hands-on

  • 「テスト駆動開発」を読む

https://www.ohmsha.co.jp/book/9784274217883/

  • Blazorで何か作るときに取り入れる

  • 社内で共有し、業務に取り入れる

2について

  • 「フロントエンド開発のためのテスト入門」を読む

https://www.amazon.co.jp/dp/B0BWR5GHMP?ref_=cm_sw_r_cp_ud_dp_S63520KYP7PBPQHAHHXD

やじはむやじはむ

FactとTheory

Fact

1つのテストメソッドに対して1つのテストのみ実行できる
例えば↓の例では1つのAddTest()というメソッドに4のみの値を入れている

Theory

複数のデータセットを定義することで1つのテストメソッドで複数のテストを実行できる
下の例ではIsOddWhenTrue()というメソッドに対し35の値をセットできている

実装部分
public static class Calculator
{
      public static int Add(int x, int y) => x + y;
      public static int Subtract(int x, int y) => x - y;
      public static bool IsOdd(int value) => value % 2 == 1;
}
xunitテストプロジェクト
public class CalculatorTests
{
    [Fact]
    public void AddTest()
    {
        Assert.Equal(4, Calculator.Add(2, 2));
    }

    [Fact]
    public void SubtractTest()
    {
        Assert.Equal(1, Calculator.Subtract(3, 2));
    }

    [Theory]
    [InlineData(3)]
    [InlineData(5)]
    public void IsOddWhenTrue(int value)
    {
       Assert.True(Calculator.IsOdd(value));
    }
}
やじはむやじはむ

SetupとTeardown

Setup:初期化処理
Teardown:終了処理

xUnit.netではテストクラスは、1つのテストメソッドを実行する都度、テストクラスのインスタンスが生成される。
その時に必要な、クラスのインスタンス生成や処理をテストクラスのコンストラクターに書き初期化処理(=Setup)を行う。
また、テスト実行後にDispose()メソッドを実行することで終了処理(=Teardown)を行い、解放する。

実装部分
public static class Files
{
    public static bool DeleteIfExist(string file)
    {
        if (!File.Exists(file))
        {
            return false;
        }

        File.Delete(file);
        return true;

    }
}
テストプロジェクト
public class FilesTests : IDisposable
{
    private const string ExistFileName = "test.txt";
    private const string TextFileContent = "Hello, xUnit.net!";
    private const string NotExistFileName = "NotExistFile";

    // setup
    public FilesTests()
    {
        if (File.Exists(ExistFileName))
            File.Delete(ExistFileName);

        File.WriteAllText(ExistFileName, TextFileContent);
    }

    // teardown
    public void Dispose()
    {
        if (File.Exists(ExistFileName))
            File.Delete(ExistFileName);
    }

    [Fact]
    public void DeleteIfExistWhenExistFile()
    {
        Assert.True(Files.DeleteIfExist(ExistFileName));
        Assert.False(File.Exists(ExistFileName));
     }

    [Fact]
    public void DeleteIfExistWhenNotExistFile()
    {
        Assert.False(Files.DeleteIfExist("NotExistFile"));
    }
}
やじはむやじはむ

非同期処理

基本的に↑と同じようにして実行できる

実装部分
public static async Task<string> ReadAllTextAsync(string file)
{
     using (var reader = new StreamReader(File.OpenRead(file)))
     {
          return await reader.ReadToEndAsync();
     }
}
テストプロジェクト
// 初期化処理は省略

[Fact]
public async Task ReadAllTextAsyncWhenExistFile()
{
     Assert.Equal(TextFileContent, await Files.ReadAllTextAsync(ExistFileName));
}

[Fact]
public async Task ReadAllTextAsyncWhenNotExistFile()
{
     // 非同期用のAssertionを使用する
     await Assert.ThrowsAsync<FileNotFoundException>(
         () => Files.ReadAllTextAsync(NotExistFileName));
    }
}
やじはむやじはむ

並行処理

xUnit.netでは単一のテストコレクションは逐次実行され、異なるテストコレクションは平行実行される
例えば以下の例である

テストプロジェクト
// 単一のテストコレクションの例
// 逐次実行なので完了まで3+5=8秒かかる
public class UnitTest1
{
     [Fact]
     public void Test1()
     {
         Thread.Sleep(3000);
     }

     [Fact]
     public void Test2()
     {
         Thread.Sleep(5000);
     }
}
テストプロジェクト
// 異なるテストコレクションの例
// 並行実行なので完了まで3<5で5秒かかる
public class UnitTest1
{
    [Fact]
    public void Test1()
    {
       Thread.Sleep(3000);
    }
}

public class UnitTest2
{
    [Fact]
    public void Test2()
    {
       Thread.Sleep(5000);
    }
}

逆に異なるテストクラス間で並列実行をしてほしくないときは以下のように同じコレクションとして登録すればOK

テストプロジェクト
[Collection("Our Test Collection #1")]
public class UnitTest1
{
    [Fact]
    public void Test1()
    {
       Thread.Sleep(3000);
    }
}

[Collection("Our Test Collection #1")]
public class UnitTest2
{
    [Fact]
    public void Test2()
    {
        Thread.Sleep(5000);
    }
}
やじはむやじはむ

共有コンテキスト

ユニットテスト間でのロジックや初期化処理などの共有の行い方

コンストラクターとDispose

以下のように共通化したい初期化処理やDisposeを別クラスに定義する

/HeavyFixures/HaevyFixures.cs
public class HeavyFixture : IDisposable
{
     // コンストラクター
     public HeavyFixture() => Thread.Sleep(TimeSpan.FromSeconds(2));

     public void Use()
     {
     }

     // Dispose
     public void Dispose() => Thread.Sleep(TimeSpan.FromSeconds(2));
 }
UnitTest1.cs
public class UnitTest1 : IDisposable
{
    // インスタンスの作成
    private readonly HeavyFixture _heavyFixture;

    public UnitTest1()
    {
        // 初期化(setup)
        _heavyFixture = new HeavyFixture();
    }

    // 共通化したheavyFixuresクラスのUseメソッドを使用
    [Fact]
    public void Test1() => _heavyFixture.Use();

    [Fact]
    public void Test2() => _heavyFixture.Use();

  // Dispose(teardown) 
    public void Dispose()
    {
        _heavyFixture.Dispose();
    }
 }

ロジックの共有はできるが、インスタンスの共有はできない(クラスごとに作成が必要)
前提として1つのテストごとにクラスのインスタンスが作られるので、上記では(初期化に2秒+Disposeに2秒)×2回=8秒が必要となる
→別のテストクラスで同じ初期化とDisposeを行う必要があるのでそのたびに重い処理を実行しなければならない
複雑なロジックの共有には適切だが、重たい処理の共有には本来向かない

クラス フィクスチャー

単一のテストクラス内でロジックとインスタンスを共有したい場合にクラスフィクスチャーを使う

UnitTest1.cs
// IClassFixture<T>を宣言する
public class UnitTest1 : IDisposable, IClassFixture<HeavyFixture>
{
    private readonly HeavyFixture _heavyFixture;

    // コンストラクター引数にフィクスチャーを追加する
    // heavyFixureインスタンス作成=初期化
    public UnitTest1(HeavyFixture heavyFixture)
    {
        _heavyFixture = heavyFixture;
    }

    // Test1とTest2でheavyFixureインスタンスを共有
    [Fact]
    public void Test1() => _heavyFixture.Use();

    [Fact]
    public void Test2() => _heavyFixture.Use();

    // Disposeはフレームワーク側が実施してくれるためコメントアウト
    public void Dispose()
    {
        //_heavyFixture.Dispose();
    }
}

今回はインスタンスもの共有できているので(初期化に2秒+Disposeに2秒)×1回=4秒で済む

コレクション フィクスチャー

異なるクラス間でコンテキストを共有したい場合にコレクションフィクスチャを使う

その前に↑でもでてきたコンストラクタでheavyFixureインスタンスを引数として受け取ってるもの何?readonlyで定義してる変数何?と思い調べた

DI(Dependency injection):依存性の注入
https://araramistudio.jimdo.com/2021/09/16/c-di-dependency-injection-依存性の注入とは/
書いてある通り、単体テストをしやすくしたり、コードを再利用しやすくするためのものらしい

本題

コレクションの定義

/Collections/HeavyCollection.cs
[CollectionDefinition("Heavy collection")]
public class HeavyCollection : ICollectionFixture<HeavyFixture>
{
    // CollectionDefinitionを付与したクラスのみ作成すればよい
    // 特別な実装は不要
}
UnitTest1.cs
// Collection属性を定義しコレクション定義の名称を設定
[Collection("Heavy collection")]
public class UnitTest1 : IDisposable
{
    private readonly HeavyFixture _heavyFixture;

    // コンストラクターで共有対象のフィクスチャーをインジェクション
    public UnitTest1(HeavyFixture heavyFixture)
    {
        _heavyFixture = heavyFixture;
    }

    [Fact]
    public void Test1() => _heavyFixture.Use();

    [Fact]
    public void Test2() => _heavyFixture.Use();

    public void Dispose()
    {
        //_heavyFixture.Dispose();
    }
}
UnitTest2.cs
// Collection属性を定義しコレクション定義の名称を設定
[Collection("Heavy collection")]
public class UnitTest2 : IDisposable
{
     private readonly HeavyFixture _heavyFixture;

   // コンストラクターで共有対象のフィクスチャーをインジェクション
     public UnitTest2(HeavyFixture heavyFixture)
     {
         _heavyFixture = heavyFixture;
     }

     [Fact]
     public void Test() => _heavyFixture.Use();

     public void Dispose()
     {
         _heavyFixture.Dispose();
     }
}

インスタンスを共有しているからクラスフィクスチャーと同様で4秒になる
ただし、コレクションフィクスチャーでは、同一コレクション内のすべてのテストメソッドは逐次実行されるためクラスフィクスチャーよりも時間はかかる
この仕様なのは、同一のリソースを利用することを想定しているからとのこと

非同期ライフタイム

共有コンテキストには、非同期に初期化・終了処理を行うための、IAsyncLifetimeが用意されており、これまでと同様に使える

/Fixures/AsyncHeavyFixures.cs
public class AsyncHeavyFixture : IAsyncLifetime
{
    public Task InitializeAsync() => Task.Delay(TimeSpan.FromSeconds(2));

    public void Use()
    {
    }

    public Task DisposeAsync() => Task.Delay(TimeSpan.FromSeconds(2));
}
UnitTest3.cs
public class UnitTest3 : IClassFixture<AsyncHeavyFixture>
{
    private readonly AsyncHeavyFixture _asyncHeavyFixture;

    public UnitTest3(AsyncHeavyFixture asyncAsyncHeavyFixture)
    {
        _asyncHeavyFixture = asyncAsyncHeavyFixture;
    }

    [Fact]
    public void Test() => _asyncHeavyFixture.Use();
}

これはクラスフィクスチャー

やじはむやじはむ

デバッグ出力

ITestOutputHelperを使うことでConsoleに出したいメッセージを出力することが出来る

UnitTest1.cs
private readonly ITestOutputHelper _output;
private readonly HeavyFixture _heavyFixture;

public UnitTest1(ITestOutputHelper output, HeavyFixture heavyFixture)
{
    _heavyFixture = heavyFixture;

    _output = output;
    _output.WriteLine("This is output from {0}", "Constructor");
}

[Fact]
public void Test1()
{
    _heavyFixture.Use();
    _output.WriteLine("This is output from {0}", "Test1");
}

console結果

やじはむやじはむ

TheoryとDataAttribute

Theoryとは複数のデータセットを定義してテストを実行できるものであった

Inlinedata

UnitTest1.cs
[Theory]
[InlineData(3)]
[InlineData(5)]
public void IsOddWhenTrue(int value)
{
    Assert.True(Calculator.IsOdd(value));
}

MemberData

InlineDataはシンプルに記述できるが、データセットの生成に何らかのロジックが必要な場合などは記述が困難になる、もしくは不可能となる

それを可能にするものにMemberdataとClassDataがある

MemberDataでは以下の3つのメンバーが利用可能

  1. メソッド
  2. プロパティ
  3. フィールド
    いずれもIEnumerable<object[]>な値を返す、publicでstaticなメンバーである必要がある
    都度データを生成する場合はメソッドを、一度生成したものを別のテストケースでも使いまわすのであればメソッド以外を利用するのが明確かもとのこと

1. メソッド

UnitTest1.cs
public static IEnumerable<object[]> GetValues() =>
     new List<object[]>
     {
          new object[]{1, 2, 3},
          new object[]{-1, -2, -3},
     };

[Theory]
[MemberData(nameof(GetValues))]
public void MemberDataTestByMethod(int x, int y, int result)
{
     Assert.Equal(result, Calculator.Add(x, y));
}

ここで出てくるnameof()ってなんだ?!

nameofは該当する変数や関数が存在しない場合はコンパイルエラーになる。どうやら保守性を高めるのに有効らしい。すぐ使えそう
https://threeshark3.com/nameof/

2. プロパティ

UnitTest1.cs
public static IEnumerable<object[]> ValuesProperty { get; } =
    new List<object[]>
    {
         new object[]{1, 2, 3},
         new object[]{-1, -2, -3},
    };

[Theory]
[MemberData(nameof(ValuesProperty))]
public void MemberDataTestByProperty(int x, int y, int result)
{
     Assert.Equal(result, Calculator.Add(x, y));
}

3. フィールド

UnitTest1.cs
public static readonly IEnumerable<object[]> ValuesField =
    new List<object[]>
    {
        new object[]{1, 2, 3},
        new object[]{-1, -2, -3},
    };

[Theory]
[MemberData(nameof(ValuesField))]
public void MemberDataTestByField(int x, int y, int result)
{
     Assert.Equal(result, Calculator.Add(x, y));
}

ClassData

MemberDataで記載するとテストケースクラスにテストデータを生成するためのコードが大量に混在してしまう場合に有効

AddTestDataSets.cs
class AddTestDataSets : IEnumerable<object[]>
{
    // メソッドを定義
    public IEnumerator<object[]> GetEnumerator()
    {
        return new List<object[]>
            {
                new object[]{1, 2, 3},
                new object[]{-1, -2, -3},
            }.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
     }
}
UnitTest1.cs
[Theory]
[ClassData(typeof(AddTestDataSets))]
public void ClassDataTest(int x, int y, int result)
{
     Assert.Equal(result, Calculator.Add(x, y));
}

ClassData属性でデータセットのTypeを指定している。文字列ではない

やじはむやじはむ

等価性の検証

MoneyTest.cs
[Fact]
public void TestEquality()
{
     Assert.True(new Dollar(5).CheckIsEquals(new Dollar(5)));
     Assert.False(new Dollar(5).CheckIsEquals(new Dollar(6)));
     Assert.True(new Franc(5).CheckIsEquals(new Franc(5)));
     Assert.False(new Franc(5).CheckIsEquals(new Franc(6)));
     Assert.False(new Franc(5).CheckIsEquals(new Dollar(5)));
}
Money.cs
public class Money
{
    protected int amount;

    public bool CheckIsEqual(Object obj)
    {
        Money money = (Money)obj;
        return amount == money.amount && this.GetType() == money.GetType();
    }
}

DollarとFrancはMoneyを継承している

やじはむやじはむ

本読み終わった。
多分4割くらいしか理解できてない;;難しかった

  • TDDは思っているものより開発を大きくDriveするものではなさそう
  • あくまで実装を手助けする感じ

TDDのTは「テスト」の一部に過ぎない。
ソフトウェアの世界における本来のテストとは、認知外を探求する、いわば創造的破壊行為です。「TDDのテストとは、いわばプログラミングや設計の補助線、治具です。
つまり、テスト駆動開発のテストは、ソフトウェアテストの世界の「テスト」の中では一部を占めるだけに過ぎません。
それは"Checking"である

  • 進め方は、テストファーストなのでまずテストから先に書き、レッドを出した後、実装を始め、グリーンが出るまでリファクタリングして、またテストして...のように繰り返していくこと

やってみた、読んでみた感じ、ただコンテンツ重視のWebサイトを作るだけであれば、わざわざテストを行う必要はなさそう。ソフトウェアみたいな機能がたくさんでより複雑なものであれば、凝集度を高くし、結合度を低くするための手助けとして導入するのはもちろん必要不可欠だと思うけど。

今1つもテストをやっていない環境からテスト手法を学び、実際のプロジェクトで導入するまでのコストや、導入できたとしてどれくらいい効果が出るのかを考えた時に、あまりメリットはないような感じがする。本にも書いてあったが、テストは実装を手助けするのはもちろんそうだが、コードが増えるという点では管理対象としての荷物が増えるということにもなるので、その均衡点みたいなものを超えていたら取り入れれば良いし、達していないならば取り入れなければいい。今はその均衡点に達していなさそうなので、これ以上必要以上に学ぶことはしない。(今後必要になりそうな時があればもちろんするが)

フロントエンドはどうなのだろうか。本が出版されたら読み進めたい。

やじはむやじはむ

例外のスローを検証するテスト

expectの引数に値ではなく、関数を渡すため() => add(-10,10)のようにアロー関数を渡す

describe("add", () => {("引数が'0〜100'の範囲外だった場合、例外をスローする", () => {
      const message = "入力値は0〜100の間で入力してください";
      expect(() => add(-10, 10)).toThrow(message);
      expect(() => add(10, -10)).toThrow(message);
      expect(() => add(-10, 110)).toThrow(message);
    });
  });
やじはむやじはむ

投げる例外でError型を継承した個別のエラークラスを定義する

// Eroorを継承したRangeErrorを定義
export class RangeError extends Error {}

function checkRange(value: number) {
  if (value < 0 || value > 100) {
    // 閾値を超えた場合はRangeErrorを投げる
    throw new RangeError("入力値は0〜100の間で入力してください");
  }
}

テストコード側はスローされるものが、作ったエラーインスタンスと等しいかで判定する

test("引数が'0〜100'の範囲外だった場合、例外をスローする", () => {
      // 定義したRangeErrorインスタンスと等しいかで判定する
      expect(() => sub(-10, 10)).toThrow(RangeError);
      expect(() => sub(10, -10)).toThrow(RangeError);
      expect(() => sub(-10, 110)).toThrow(Error);
    });

配列の検証

describe("配列の検証", () => {
  describe("プリミティブ配列", () => {
    const tags = ["Jest", "Storybook", "Playwright", "React", "Next.js"];
    test("toContain", () => {
      expect(tags).toContain("Jest");
      expect(tags).toHaveLength(5);
    });
  });
  describe("オブジェクト配列", () => {
    const article1 = { author: "taro", title: "Testing Next.js" };
    const article2 = { author: "jiro", title: "Storybook play function" };
    const article3 = { author: "hanako", title: "Visual Regression Testing " };
    const articles = [article1, article2, article3];
    test("toContainEqual", () => {
      expect(articles).toContainEqual(article1);
    });
    // arrayContaining は引数の配列要素全てが検証先の配列に含めれていればOK
    test("arrayContaining", () => {
      expect(articles).toEqual(expect.arrayContaining([article1, article3]));
    });
  });
});
やじはむやじはむ

非同期処理のテストでtry-catchを使う場合

// try-cathcを使う場合
test("指定時間待つと、経過時間をもって reject される", async () => {
  // アサーションの数を指定して正しい数のテストが行われているかを検証する
  // このテストでは、1つのアサーションが行われることを期待している
  expect.assertions(1);
  try {
    await timeout(50); // timeout関数のつもりが、wait関数にしてしまった
    // ここで終了してしまい、テストは成功する
  } catch (err) {
    // アサーションは実行されない
    expect(err).toBe(50);
  }
});
  • 非同期処理を含むテストは、テスト関数をasync関数で書く
  • .resolversや.rejectsを含むアサーションはawaitする
  • try-catch文による例外スローを検証する場合はexpect.assertionsを書く
やじはむやじはむ

レスポンスを再現するテストデータのことをフィクスチャーと呼び、fixtures.tsのようなファイルに書く

やじはむやじはむ

アクセシブルネームの使用

formとその配下にあるinputなど、あるまとまり(group)に対してテストを実行したい時には、アクセシビリティの高いHTML要素を使用する

例えば、

type Props = {
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
};

export const Agreement = ({ onChange }: Props) => {
  return (
    <fieldset>
      <legend>利用規約の同意</legend>
      <label>
        <input type="checkbox" onChange={onChange} />
        当サービスの<a href="/terms">利用規約</a>を確認し、これに同意します
      </label>
    </fieldset>
  );
};

この<fieldset>は暗黙のロールとして"group"ロールを持ち、テストの際にひとまとまりのグループとして特定できる。次にある<legend>は、グループのタイトルをつける時に使用する要素。

<div>だと、ひとまとまりのグループとして特定ができないためテストを行えなくなってしまう。そのため、テストを書くと同時にアクセシビリティも高めるという一石二鳥なことができる。

やじはむやじはむ

<input type="password">がロールを持たない理由
https://github.com/w3c/aria/issues/935

useId()を使ってformタグにアクセシブルネームを付与する。これがあることで、formロールが適用され、テストできるようになる。
useId()はReact 18で導入されたもので、aria-labelledby属性に唯一のIDを渡すのに使う。

import { useId, useState } from "react";
import { Agreement } from "./Agreement";
import { InputAccount } from "./InputAccount";

export const Form = () => {
  const [checked, setChecked] = useState(false);
  const headingId = useId();
  return (
    <form aria-labelledby={headingId}>
      <h2 id={headingId}>新規アカウント登録</h2>
      <InputAccount />
      <Agreement
        onChange={(event) => {
          setChecked(event.currentTarget.checked);
        }}
      />
      <div>
        <button disabled={!checked}>サインアップ</button>
      </div>
    </form>
  );
};
やじはむやじはむ
export type AddressOption = React.ComponentProps<"option"> & { id: string };
export type Props = {
  deliveryAddresses?: AddressOption[];
  onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void;
};

option要素が持つ属性たちとidを持つpropsの定義

deliveryAddressesに入れるもの↓

export const deliveryAddresses = [
  {
    id: "address_id_xxxx",
    value: "address_id_xxxx",
    children: "〒167-0051 東京都杉並区荻窪1-00-00",
  },
];
やじはむやじはむ

onSubmitで使用するフォーム送信用のモック関数

function mockHandleSubmit() {
  const mockFn = jest.fn();
  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const data: { [k: string]: unknown } = {};
    formData.forEach((value, key) => (data[key] = value));
    mockFn(data);
  };
  console.log(mockFn);
  console.log(onSubmit);
  return [mockFn, onSubmit] as const; //タプルとして複数の戻り値をconst(=readonly)として定義
}
やじはむやじはむ

Formの一般化・抽象化

使う方

import { useState } from "react";
import { Form } from "../06/Form";
import { postMyAddress } from "./fetchers";
import { handleSubmit } from "./handleSubmit";
import { checkPhoneNumber, ValidationError } from "./validations";

export const RegisterAddress = () => {
  const [postResult, setPostResult] = useState("");
  return (
    <div>
      <Form
        onSubmit={handleSubmit((values) => {
          try {
            checkPhoneNumber(values.phoneNumber);
            postMyAddress(values)
              .then(() => {
                setPostResult("登録しました");
              })
              .catch(() => {
                setPostResult("登録に失敗しました");
              });
          } catch (err) {
            if (err instanceof ValidationError) {
              setPostResult("不正な入力値が含まれています");
              return;
            }
            setPostResult("不明なエラーが発生しました");
          }
        })}
      />
      {postResult && <p>{postResult}</p>}
    </div>
  );
};

定義してる方

import { useState } from "react";
import { ContactNumber } from "./ContactNumber";
import { DeliveryAddress } from "./DeliveryAddress";
import { PastDeliveryAddress } from "./PastDeliveryAddress";
import { RegisterDeliveryAddress } from "./RegisterDeliveryAddress";

export type AddressOption = React.ComponentProps<"option"> & { id: string };
export type Props = {
  deliveryAddresses?: AddressOption[];
  onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void;
};
export const Form = (props: Props) => {
  const [registerNew, setRegisterNew] = useState<boolean | undefined>(
    undefined
  );
  return (
    <form onSubmit={props.onSubmit}>
      <h2>お届け先情報の入力</h2>
      <ContactNumber />
      {props.deliveryAddresses?.length ? (
        <>
          <RegisterDeliveryAddress onChange={setRegisterNew} />
          {registerNew ? (
            <DeliveryAddress title="新しいお届け先" />
          ) : (
            <PastDeliveryAddress
              disabled={registerNew === undefined}
              options={props.deliveryAddresses}
            />
          )}
        </>
      ) : (
        <DeliveryAddress />
      )}
      <hr />
      <div>
        <button>注文内容の確認へ進む</button>
      </div>
    </form>
  );
};

handleSubmit関数の定義

export function handleSubmit(callback: (values: any) => Promise<void> | void) {
  return (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const values: { [k: string]: unknown } = {};
    formData.forEach((value, key) => (values[key] = value));
    return callback(values);
  };
}

formのデータをKVの形に整形している関数

valuesには以下のようなデータが入る

{
      phoneNumber: '000-0000-0000',
      name: '田中 太郎',
      postalCode: '167-0051',
      prefectures: '東京都',
      municipalities: '杉並区荻窪1',
      streetNumber: '00-00'
}
やじはむやじはむ

AAA(Arrange-Act-Assert)パターン
Arrange: 準備、Act: 実行、Assert: 検証のこと
可読性が高くなるという特徴がある

test("バリデーションエラー時「不正な入力値が含まれています」が表示される", async () => {
  render(<RegisterAddress />);  // Arrange
  await fillInvalidValuesAndSubmit();  // Act
  expect(screen.getByText("不正な入力値が含まれています")).toBeInTheDocument();  // Assert
});
やじはむやじはむ

スナップショットテストでリグレッションを発生させる

正しい状態の(今回だとtaro)スナップショットがgit上にコミットされている状態で、以下のような間違ったスナップショットを作成する

test("Snapshot: アカウント名「taro」が表示される", () => {
  const { container } = render(<Form name="jiro" />);  // taroではなくjiroにしてみる
  expect(container).toMatchSnapshot();
});

そうするとgit上でdiffが発生し、間違っていることがわかる

意図的な変更によるテストの失敗を成功させる

npx jest --updateSnapshot
または
npx jest -u

上記のコマンドを実行することで新しい状態のスナップショットに更新させる

やじはむやじはむ

カバレッジレポート

File:ファイル名称
Stmts:命令網羅率 全てのステートメントが少なくとも1回以上実行されたかを示す分数
Branch:分岐網羅率 全ての条件分岐が少なくとも1回以上実行されたかを示す分数
Funcs:関数網羅率 全ての関数が少なくとも1回以上呼び出されたかを示す分数
Lines:全ての行を少なくとも1回通過したかを示す分数

以下のコマンドでカバレッジレポートを生成
coverageフォルダーが自動生成される

npx jest --coverage

以下のコマンドを実行することでカバレッジレポートを視覚的に把握できる

open coverage/lcov-report/index.html

カバレッジレポートの例

黄色と赤色はテストが不足していることを示す

↓こんな感じで網羅されていない部分は赤く塗りつぶされる
カバレッジレポートでテストが不足しているコードが赤く出力される

実装内部構造を把握し、論理的に書く「ホワイトボックステスト」にカバレッジレポートは欠かせません

やじはむやじはむ

テストの話ではないが、JSXで以下のようにスプレッド構文でオブジェクトのkeyとvalueの対になる形でHTML要素の属性を書くと、HTMLに変換した際に以下のようにしてくれる

<a {...{"aria-current": "page"}}><a aria-current="page">

プロパティの省略記法というものもある

const status = event.target.value;
push({ query: { ...query, status } });

これは、statusというプロパティにstatus="private"の値が入るようにできる
オブジェクトを生成するときにプロパティと同じ変数名の値であればプロパティの名前を省略して値だけを記述することができる

やじはむやじはむ

Next.jsのRouterに関するテストをJestで行いたいときはnext-router-mockを使う

pnpm add -D next-router-mock

使い方

test("「My Posts」がカレント状態になっている", () => {
  mockRouter.setCurrentUrl("/my/posts");  // 現在URLが"/my/posts"であることを仮定する
  render(<Nav onCloseMenu={() => {}} />);
  const link = screen.getByRole("link", { name: "My Posts" });
  expect(link).toHaveAttribute("aria-current", "page");
});

test.eachを使ったパターン

test.each([
  { url: "/my/posts", name: "My Posts" },
  { url: "/my/posts/123", name: "My Posts" },
  { url: "/my/posts/create", name: "Create Post" },
])("$url では $name がカレントになっている", ({ url, name }) => {
  mockRouter.setCurrentUrl(url);
  render(<Nav onCloseMenu={() => {}} />);
  const link = screen.getByRole("link", { name });
  expect(link).toHaveAttribute("aria-current", "page");
});

ただ、これらはNext.js 12以前のものなのでApp Routerで機能しない可能性が大きい

やじはむやじはむ

上と下の場合のテストだと全然早さが違う

checkbox.test.tsx
// 各テストケースにおいてarrangeとactが実行されてしまい、実行時間が長くなる
describe(`フォームのチェックボックスの処理`, () => {
  describe.each(formCheckboxCases)(
    '$checkboxNamesToClick',
    ({ checkboxNamesToClick, expected }) => {
      it.each(expected.checkboxes)(
        'チェックボックスの状態が\n%s\nとなる',
        async (expectedCheckbox) => {
          const utils = await arrangeFormCheckbox();
          await act(utils, checkboxNamesToClick);

          const checkbox = await screen.findByRole('checkbox', {
            name: expectedCheckbox.name,
          });
          const ariaChecked = checkbox.getAttribute('aria-checked');
          expect(ariaChecked).toBe(expectedCheckbox.checked);
          expect(checkbox.disabled).toBe(expectedCheckbox.disabled);
        },
      );
    },
  );
});

// テストケース1つ(itが1つ)に対してarrange,actが1回しか実行されていないので、実行時間が早くなる
describe('ワークブック問題選択でstep_listのツリーを持つ場合のチェックボックスの処理', () => {
  describe.each(stepListCheckboxCases)(
    '$checkboxNamesToClick',
    ({ checkboxNamesToClick, expected }) => {
      const expectedCheckboxesJson = JSON.stringify(expected.checkboxes, undefined, 2);

      it(`チェックボックスの状態が\n${expectedCheckboxesJson}\nとなる`, async () => {
        const utils = await arrangeStepListCheckbox();
        await act(utils, checkboxNamesToClick);

        await Promise.all(
          const checkbox = await screen.findByRole('checkbox', {
            name: expectedCheckbox.name,
          });
            const ariaChecked = checkbox.getAttribute('aria-checked');
            expect(ariaChecked).toBe(checked);
            expect(checkbox.disabled).toBe(disabled);
          }),
        );
      });
    },
  );
});
このスクラップは2023/11/01にクローズされました