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

自分について少し
私はivi.ruでフロントエンド開発に従事しています。 モバイルアプリケーションと同じAPIを使用しているため、動作と表示のすべての基本的なロジックの実装はクライアント側に委ねられます。 多数の画面があると考えると、かなり大きなコードベースが得られます。その品質は何らかの形で監視する必要があります。 そのため、TDDを積極的に実践しています。 私たちはすべてOOPマニアなので、テストは厳密なオブジェクト指向の規範に従って編成されています。
単体テストを整理するときに経験した苦痛、およびそれがどのように対処されたかについて、さらに説明します。
理論のビット
NB! 以下では、「モジュール」、「クラス」、および「サブシステム」という言葉を同義語として使用しますが、実際には必ずしもそうとは限りません。モジュール接続
ソフトウェア設計では、多くの場合、モジュールに分割されるコードの品質を表す2つの特性、結合と結合を見つけることができます。 通常、彼らは「低結合」と「高結合」の原則について話します。 それはどういう意味ですか?
- 低結合 、または低ペアリングは、アプリケーションモジュールが最小限に他者に依存し、必要な機能のみを認識することを意味します。 これは、適切な設計により、1つのモジュールを変更するときに、他のモジュールを編集する必要がないか、これらの変更が最小限であることを意味します。
- 結束性が高い 、つまり接続性が高いということは、モジュール内のすべての機能が一貫しており、特定の問題の解決に焦点を合わせていることを意味します。 これは、適切な設計により、モジュールがコンパクトで理解しやすく、「余分な」コードや副作用が含まれないことを意味します。
単体テスト
ユニットテストは、「ブラックボックス」の原則に従って個々のシステムモジュールをテストすることです。 つまり、特定の機能を担当するクラスまたはクラスのセットが取得され、テストデータが入力され、作業の結果が参照と比較されます。
ユニットテストを実装するには、モジュールの実際の外部依存関係の代わりに、いわゆるモックオブジェクト、つまり「実際の」機能をテストのものに置き換えるオブジェクトが使用されます。
多くの場合、テクニック(
TDD 、
BDD )が使用されます。このテクニックでは、最初にまだ存在しないコードに対してテストが記述され、次にテストされた機能を実装するモジュール自体が記述されます。 これは、テストカバレッジの観点だけでなく、モジュールの適切なアーキテクチャ編成の観点からも有用です。最初に「ブラックボックス」の外部インターフェイスを設計し、次に実装に真っ向から取り組むからです。
多くのアーキテクチャエラーは、テストを書く段階で特定できます。なぜなら、コードがテストに便利であれば、高い確率で、結合が少なく、接続性が高いからです。 テストされたコードの共役が高い場合、テストは複雑でロジックが豊富なモックオブジェクトを生成し、接続性が低い場合は、入力データと出力データの多くの類似または複雑なケースと組み合わせがあります。
たくさんの練習
この記事で解決する主な問題は、単体テストが簡単でコードがすっきりするようにコードを編成する方法の問題です。
例は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!'); });
このアプローチの長所と短所は非常に明白です。
- +そのようなコードは簡単に記述できます。
- +プロジェクトにテストがほとんどなく、それらを使用して複雑なバグをキャッチする場合に便利です。
- -テストされるのはクラスAではなく、機能全体です。 この層が大きく、機能が複雑な場合、テストは膨大でわかりにくくなります。 概して、これは単体テストではなく、 I&Tです。
- -コードBを変更する場合、それを使用するモジュールのすべてのテストを編集する必要があります。
- -このようなテストは、開発者にコードをモジュールに正しく分割するよう促しません。
オンザフライ方式の再定義
「それでは、必要なフィールドを再定義してみましょう」と言います。たとえば、次のようになります。
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つのモジュールのみをテストし、その実装のみに依存します
- -プロジェクトが小さく、サブシステムが小さい場合にのみ機能します
共有インターフェース
この記事の目的、つまり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() + '!'; } }
したがって、各依存関係の各モジュールには、このモジュールで使用されるものだけを記述するインターフェースがあります。
- +各モジュールは、他のモジュールについて知っておくべきことだけを知っています。
- +モジュールの1つに対するローカルな変更は、このモジュールのテストのみに影響します。
- +メソッドの1つを変更すると、このインターフェースを直接使用するモジュールのみが変更されます。
- -多数のインターフェイスとmobクラスは、コードの方向を複雑にします。
結論の代わりに
実際には常に判明しているように、何らかのハイブリッドアプローチを使用するのが最も便利です。 たとえば、このプロジェクトでは、インターフェイスの分離を大規模なサブシステムにのみ使用し、クラスの内部ではモックオブジェクトを単純な拡張にしています。
いずれにせよ、記述されたパターンは、TDDでの作業をより簡単にします。 上記で書いたように、適切に編成されたテストは
、実装前にアーキテクチャの問題を特定するのに役立ち、これにより開発者の工数と管理者の神経が節約されました。
ここで説明するすべての例は、 githubで表示できます 。
この記事の執筆を支援してくださったdarkarturに感謝します。