個別のユニットテストインターフェイス

当社のブログによると、データマイニングとネットワークのみに従事しているように見えるかもしれません。 したがって、開発ワークショップの代表として、ユニットテストとモジュールへのコードの分離がフロントエンドでどのように編成されているかについての記事を書く喜びを否定できませんでした。







自分について少し



私はivi.ruでフロントエンド開発に従事しています。 モバイルアプリケーションと同じAPIを使用しているため、動作と表示のすべての基本的なロジックの実装はクライアント側に委ねられます。 多数の画面があると考えると、かなり大きなコードベースが得られます。その品質は何らかの形で監視する必要があります。 そのため、TDDを積極的に実践しています。 私たちはすべてOOPマニアなので、テストは厳密なオブジェクト指向の規範に従って編成されています。



単体テストを整理するときに経験した苦痛、およびそれがどのように対処されたかについて、さらに説明します。



理論のビット



NB! 以下では、「モジュール」、「クラス」、および「サブシステム」という言葉を同義語として使用しますが、実際には必ずしもそうとは限りません。



モジュール接続



ソフトウェア設計では、多くの場合、モジュールに分割されるコードの品質を表す2つの特性、結合と結合を見つけることができます。 通常、彼らは「低結合」と「高結合」の原則について話します。 それはどういう意味ですか?





単体テスト



ユニットテストは、「ブラックボックス」の原則に従って個々のシステムモジュールをテストすることです。 つまり、特定の機能を担当するクラスまたはクラスのセットが取得され、テストデータが入力され、作業の結果が参照と比較されます。



ユニットテストを実装するには、モジュールの実際の外部依存関係の代わりに、いわゆるモックオブジェクト、つまり「実際の」機能をテストのものに置き換えるオブジェクトが使用されます。



多くの場合、テクニック( TDDBDD )が使用されます。このテクニックでは、最初にまだ存在しないコードに対してテストが記述され、次にテストされた機能を実装するモジュール自体が記述されます。 これは、テストカバレッジの観点だけでなく、モジュールの適切なアーキテクチャ編成の観点からも有用です。最初に「ブラックボックス」の外部インターフェイスを設計し、次に実装に真っ向から取り組むからです。



多くのアーキテクチャエラーは、テストを書く段階で特定できます。なぜなら、コードがテストに便利であれば、高い確率で、結合が少なく、接続性が高いからです。 テストされたコードの共役が高い場合、テストは複雑でロジックが豊富なモックオブジェクトを生成し、接続性が低い場合は、入力データと出力データの多くの類似または複雑なケースと組み合わせがあります。



たくさんの練習



この記事で解決する主な問題は、単体テストが簡単でコードがすっきりするようにコードを編成する方法の問題です。



例はTypeScriptで提供されていますが、このアプローチは強く型付けされたオブジェクト指向言語(Java、C ++、ObjC)に有効です。



したがって、最も単純なアプリケーションの問題を検討してください。



helloworldクラスAがあるとします。そのコードは次のようになります。



class A { greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } private b: B = new B(); }
      
      





ご覧のとおり、このクラスには外部依存関係があります-B



 class B { getName(): string { return 'Habr'; } }
      
      





私たちのタスクは、すべてのクラスA機能をテストでカバーすることです。



すべてをテストする



最も単純な方法は「額」です。つまり、すべてのロジックを一度にテストします。



  it('test', ()=>{ var a: A = new A(); expect(a.greeting()).toBe('Hello, Habr!'); });
      
      





このアプローチの長所と短所は非常に明白です。





オンザフライ方式の再定義



「それでは、必要なフィールドを再定義してみましょう」と言います。たとえば、次のようになります。



  it('test', ()=>{ var a: A = new A(); a['b'] = { getName: ()=>'Test' }; expect(a.greeting()).toBe('Hello, Test!'); });
      
      





問題は解決されたように見えますが、そうではありません。フィールドbがクラス内で動的に作成される場合、これを常に監視し、テスト値を確認する必要があります。 要約すると:





テストしたクラスから継承する



実際、これは前の例と同じ方法で、厳密に型指定された言語にのみ適合しています。 まず、クラスAのフィールドbをプライベートではなく保護し、モッククラス、Aのラッパーを作成します。



 class MockA extends A { constructor() { super(); this.b = { getName: ()=>'Test' }; } }
      
      





この新しいクラスをテストします。



  it('test', ()=>{ var a: A = new MockA(); expect(a.greeting()).toBe('Hello, Test!'); });
      
      







中毒注射



もちろん、依存関係管理のタスクは新しいものではなく、解決策があります。 おそらく、既にDependency Injectionについて聞いたことがあるでしょう、要するに、これはモジュールが依存関係を管理しないアプローチですが、それ自体は外部から(たとえば、コンストラクターを介して)やってくるアプローチです。



この場合、次のようになります。



 class A { constructor(private b: B) {} greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } }
      
      





次に、テスト自体でクラスBをラップできます。



 class MockB extends B { public getName() { return 'Test'; } }
      
      





そして、モカラッパーをAに渡します。



  it('test', ()=>{ var a: A = new A(new MockB()); expect(a.greeting()).toBe('Hello, Test!'); });
      
      







インターフェイスインジェクションインジェクション



クラスから拡張することは必ずしもそれほど簡単ではなく、クラスに実装されている機能には、(このテストのために)誤った副作用が生じる可能性があります。 この問題を宣言することは、依存関係として使用するモジュールのインターフェイスを宣言するのに役立ちます。



 interface IB { getName(): string; }
      
      





次に、実際のクラスBから継承する代わりに、単にそのインターフェイスを実装します。



 class MockB implements IB { getName() { return 'Test'; } }
      
      





テストは前の例と同じようになります。



  it('test', ()=>{ var a: A = new A(new MockB()); expect(a.greeting()).toBe('Hello, Test!'); });
      
      







共有インターフェース



この記事の目的、つまり1つのサブシステムのインターフェースの分離に直接進みます。 外国の文献では、これは「インターフェース分離」と呼ばれることがあります



ここで、多数のモジュールを含む大規模なプロジェクトがあることを想像してみましょう。 クラスAがまだBから1つのメソッドのみを使用するようにしますが、他のモジュールはそれを積極的に使用し、他のメソッド(多くのメソッドがある可能性があります)を使用します。 この場合、IBインターフェイスは非常に大きくなります。



 interface IB { getName(): string; getLastName(): string; getBirthDate(): Date; }
      
      





さて、テストされたクラスAのモックオブジェクトを作成するには、不必要なメソッドをさらに定義する必要があります。



 class MockB implements IB { getName() { return 'Test'; } getLastName():string { return undefined; } getBirthDate():Date { return undefined; } }
      
      





モジュールが10以上のメソッドを持つ他のいくつかのモジュールに依存している場合、どのテキストの壁が得られるか想像してみてください。 さらに、このため、モジュールが使用しない別のモジュールのメソッドを「認識」しているという事実により、高い共役が得られます。 これは、いずれかのメソッドのシグネチャを変更する場合、変更されたメソッドを使用するテストだけでなく、すべてのテストでコードを変更する必要があるという事実につながります。



この過剰な認識を避けるために、特定のサブシステムのインターフェイスを分離します 。 IBインターフェースから、各モジュールが使用するメソッドのセットを選択し、それらを個別のインターフェースにグループ化します。 この場合、次のようになります。



 export interface IBForA { getName(): string; } export interface IBForSomeOtherModule { getLastName(): string; getBirthDate(): Date; }
      
      





これらすべてのインターフェイスの結合は、クラスBを実装する必要があります。



 export interface IB extends IBForA, IBForSomeOtherModule { } class B implements IB { public getName(): string { return 'Habr'; } public getLastName():string { return 'last'; } public getBirthDate():Date { return new Date(); } }
      
      





次に、クラスAはIBインターフェイス全体に依存せず、それ自体にのみ依存します。



 class A { constructor(private b: IBForA) { } greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } }
      
      





したがって、各依存関係の各モジュールには、このモジュールで使用されるものだけを記述するインターフェースがあります。





結論の代わりに



実際には常に判明しているように、何らかのハイブリッドアプローチを使用するのが最も便利です。 たとえば、このプロジェクトでは、インターフェイスの分離を大規模なサブシステムにのみ使用し、クラスの内部ではモックオブジェクトを単純な拡張にしています。



いずれにせよ、記述されたパターンは、TDDでの作業をより簡単にします。 上記で書いたように、適切に編成されたテストは、実装前にアーキテクチャの問題を特定するのに役立ち、これにより開発者の工数と管理者の神経が節約されました。



ここで説明するすべての例は、 githubで表示できます



この記事の執筆を支援してくださったdarkarturに感謝します。



All Articles