web-dev-qa-db-ja.com

単体テストとループの予期しない入力をテストしていますか?

私はいくつかのAngular/TypeScriptプロジェクトがある企業で作業しており、それらの間でコードの繰り返し(基本的にコピーと貼り付け)を回避するために、Monorepoに進み、単体テスト、ドキュメント、およびすべてを備えたutilライブラリーの作成を開始することにしました。

現在、util関数を実装しています。

export const normalizeNames = (value: string): string => {
  if (!isString(value)) {
    // throw some error
  }

  // ...
}

会社が一般にテストの概念に比較的慣れているように、私もそうです。

テストの構成方法とテストの基準を確立しようとして、行き詰まっているので、ここでこの質問を開くことにしました。

最初に思いついたのは、それらを2つの主要なグループに分けることでした。

  • Invalid->無効なtypeごとのテスト、たとえば、1 null、1 undefined、1 NaN、1 boolean、1 number、1 arrayなど、BufferMapObjectRegExpSetなど。
  • 有効;

... このようなもの:

describe('normalizeNames', () => {
  describe('invalid', () => {
    it(`should throw error for the value 'null'`, () => {
      expect(() => normalizeNames(null as any)).toThrowError(
        TypeError,
      );
    });

    it(`should throw error for the value 'undefined'`, () => {
      expect(() => normalizeNames(undefined as any)).toThrowError(
        TypeError,
      );
    });

    // other types
  });

  describe('valid', () => {
    it(`should return '' for the value ''`, () => {
      expect(normalizeNames('')).toBe('');
    });

    it(`should return 'Stack' for the value 'stack'`, () => {
      expect(normalizeNames('stack')).toBe('Stack');
    });

    // ... more tests
  });
});

...しかし、私が想像できるすべてのtypesをテストすると、テストが大きくなりすぎて、メンテナンスが困難になる可能性があることに気付きました。

私が考えた別の解決策は、2つのArraysを作成し、繰り返しを避けるために以下のようなことをすることです:

const invalidTestCases = [
  { actual: null, expected: TypeError },
  { actual: undefined, expected: TypeError },
  // more...
];
const validTestCases = [
  { actual: '', expected: '' },
  { actual: 'stack', expected: 'Stack' }, // it's just a sample data
  // more...
];

describe('normalizeNames', () => {
  describe('invalid', () => {
    for (const { actual, expected } of invalidTestCases) {
      it(`should throw error for the value '${actual}'`, () => {
        expect(() => normalizeNames(actual as any)).toThrowError(
          expected,
        );
      });
    }
  });

  describe('valid', () => {
    for (const { actual, expected } of validTestCases) {
      it(`should return '${expected}' for the value '${actual}'`, () => {
        expect(() => normalizeNames(actual as any)).toBe(expected);
      });
    }
  });
});

したがって、質問は基本的に次のとおりです。

  1. これらの2つの主要な「グループ」でテストを分離しても大丈夫ですか?
  2. すべての可能な「タイプ」のテストを行うことは許容されますか?それ以外の場合、無効なテストに対してどのエントリを推奨しますか?
  3. 2番目の解決策:loopsを使用してテストをそのように記述することは良い習慣ですか?
4
dev_054

原則として、あなたのチームが思いついて同意するどんな慣習も良いです。プロジェクトで一貫しているだけです。

私はあなたが説明している規則を正確に使用するチームで働いてきました、そしてそれは私たちにとってうまくいきました。

それぞれの質問に詳細を与えるには:

これらの2つの主要な「グループ」でテストを分離しても大丈夫ですか?

yes!describeブロックは、テストを読みやすくするためにテストをグループ化するためにあります。これは仕様の「ヘッダー」のようなものです。テストはコードのドキュメントです。今後の読者がdescribeブロックを「スキャン」して、関心のあるテストを見つけられるように、テストをグループ化します。

すべての可能な「タイプ」のテストを行うことは許容されますか?

もちろん!テストが必要だと思われる場合は、テストする必要があります。テストする必要がないと思うなら、とにかくテストしたいかもしれません。ユニットテストは信じられないほど速く実行されます、そしてテストをうまく整理している限り、「ユニットテストが多すぎる」と不満を言う人はほとんどいません。

ループを使ってテストを書くのは良い習慣ですか?

Sure!テストコードは、「製品」コードと同じように扱う必要があります。あなたが考えるどんな原則も、プロダクションコードにとって重要であり、テストコードにとっても重要です。したがって、重複を減らし、物事を整理して読みやすくするためのツールは素晴らしいアイデアです。

これをさらに簡単にするために過去にチームで行ったことの1つは、ループで渡すオブジェクトにテストケースの「説明」を追加することです。これは、将来の読者がさまざまなケースが重要である理由を理解するのに役立ちます。また、「テストケース」をitブロックの近くに配置して、将来の読者が大量にスクロールする必要がないようにしています。

このようなもの:

describe('normalizeNames', () => {   
    ... // other tests

    describe('valid input', () => {
        [
            {
                description: 'empty strings normalize as empty string',
                input: '',
                expected: ''
            },
            {
                description: 'names with hyphens are treated as a single Word',
                input: 'sOme-Named-pErSon',
                expected: 'Some-named-person'
            },
            {
                description: 'names with spaces are treated as multiple words',
                input: 'some person name',
                expected: 'Some Person Name'
            },
            // other test cases for your business logic...
        ].forEach({ input, expected, description }) {
            it(`can normalizeNames for valid input - ${description}.  input: '${input}', expected: '${expected}'`, () => {
                expect(() => normalizeNames(actual)).toBe(expected);
            });
        }   
    }); 
});
6
Caleb

通常、テストでは1つのものをテストし、テスト対象を説明する名前を付ける必要があります。

テストにループを入れて、問題が発生する可能性のある多くのことをテストします。

  1. 最初のエラーが発生したときに停止しますか?
  2. エラーが発生すると、失敗したケースがわかりますか?
  3. テストでエラーが発生するほど複雑になっていますか

@docがコメントで言及しているように、これを回避する方法はデータ駆動型テストです。

データ駆動型テストでは、複数のテストに同じコードを使用して、テスト出力に新しい名前を生成し、それぞれに異なる入力を使用できます。

[TestCase(null)]
[TestCase(MaxInt)]
[etc]
InputShouldBeValid(input:any)
{
      ...test and assert
}
3
Ewan

ここで既存の回答に追加-

テストのさまざまなケースをすべて列挙する代わりに、入力に関係なく常にtrueになるコードのプロパティについて考えます。

その後、プロパティベースのテストフレームワークを適用して、値の生成+アサーションを実行できるようにします。詳細はこちら: https://marmelab.com/blog/2019/04/18/property-based-testing-js.html

高速チェックなどのライブラリを使用したプロパティベースのテストは、網羅的な列挙の興味深い代替手段です。実装コードとチェックしたいプロパティの仕様の両方でバグを見つけることができます。どちらの場合でも、これは開発者が開発している製品に自信を持つのに役立ちます。

2
unclelim12