SAP Cloud SDK for JavaScriptを使ってみる （3）テストを書く

##はじめに
この記事は、[SAP Cloud SDK for JavaScriptを使ってみる](https://qiita.com/tami/items/a0280ea080b24f087e68)シリーズの3回目です。

SAP Cloud SDK for JavaScriptのプロジェクトはNestJSをベースに作られており、NestJSは[Jest](https://github.com/facebook/jest)と[Supsertest](https://github.com/visionmedia/supertest)を使用したテストの仕組みを備えています。この仕組みを利用したUnit Test、およびe2e(end-to-end) Testを書いてみるというのが今回のテーマです。

※NestJSでのテストについては、以下の動画がわかりやすかったです。
[NestJS Testing Tutorial | Unit and Integration Testing](https://www.youtube.com/watch?v=dXOfOgFFKuY)

##テストのための設定
NestJSでは、テストのためあらかじめ以下の設定が用意されています。
###ソース
末尾に`.spec.ts`とつくものがテスト用のソースです。srcフォルダにあるものがUnit Test用、testフォルダにある`e2e-spec.ts`とつくものがe2e Test用です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/101e21db-0a73-7b1d-8977-846710dd832a.png)

###スクリプト
package.jsonにはテスト用のスクリプトが定義されています。今回、Unit Testでは`test`または`test:watch`、e2e Testでは`test:e2e`を使用します。
`test:watch`は、テスト対象のファイルが変更されると自動的にテストを再実行してくれます。

```json:package.json
  "scripts": {
    ...
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json",
    ...
    "ci-integration-test": "jest --ci --config ./test/jest-e2e.json",
    "ci-backend-unit-test": "jest --ci"
  },
```
`npm run test`を実行してみると、以下のように結果が表示されます。
BusinessPartnerControllerのDependencyが解決できなかったというエラーになっていますが、これについては後ほど対応します。

```
 PASS  src/app.controller.spec.ts (13.177 s)
 PASS  src/business-partner/business-partner.service.spec.ts (15.342 s)
 FAIL  src/business-partner/business-partner.controller.spec.ts (15.383 s)
  ● BusinessPartnerController › should be defined

    Nest can't resolve dependencies of the BusinessPartnerController (?). Please make sure that the argument BusinessPartnerService at index [0] is available in the RootTestModule context.
...
---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   52.17 |        0 |   42.86 |   47.06 |                   
 src                             |      50 |        0 |      75 |      45 |                   
  app.controller.ts              |     100 |      100 |     100 |     100 |                   
  app.module.ts                  |       0 |      100 |     100 |       0 | 1-12              
  app.service.ts                 |     100 |      100 |     100 |     100 |                   
  main.ts                        |       0 |        0 |       0 |       0 | 1-8
 src/business-partner            |      55 |      100 |       0 |      50 | 
  business-partner.controller.ts |      75 |      100 |       0 |   66.67 | 8,13
  business-partner.module.ts     |       0 |      100 |     100 |       0 | 1-9
  business-partner.service.ts    |   83.33 |      100 |       0 |      75 | 7
---------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 failed, 2 passed, 3 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        26.452 s
```

`npm run test:e2e`を実行すると、以下のように結果が表示されます。こちらは正常終了しました。

```
 PASS  test/app.e2e-spec.ts (12.101 s)
  AppController (e2e)
    √ / (GET) (821 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 | 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        12.366 s
Ran all test suites.
```

テストを実行すると、`s4hana_pipeline/reports`配下に実行結果のxmlファイルができます。
`/backend-unit`の下にあるのがUnite Testの結果で、`/backend-integration`の下にあるのがe2e Testの結果です。xmlファイルを直接見てもあまりピンときませんが、CI/CDのパイプラインの中で実行するとテスト結果を見やすい形にして表示してくれます。これについては、次の回で紹介したいと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/43d216d0-97e8-ed2b-e064-a05927b4a2ed.png)

以降では、実際にUnit Test, Integration Testをどのように書くかを見ていきます。

##Unit Test
###Controllerのテスト
`business-partner.controller.ts`の処理を`business-partner.controller.spec.ts`でテストします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/df38deeb-8266-2255-3f9c-99d4d9c35d2a.png)
`business-partner.controller.spec.ts`のソースは以下のようになっています。

```javascript
import { Test, TestingModule } from '@nestjs/testing';
import { BusinessPartnerController } from './business-partner.controller';

describe('BusinessPartnerController', () => {
  let controller: BusinessPartnerController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [BusinessPartnerController],
    }).compile();

    controller = module.get<BusinessPartnerController>(
      BusinessPartnerController,
    );
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

```

この状態でテストを実行すると、以下のエラーになりました。これの意味するところは、BusinessPartnerControllerが必要としているBusinessPartnerServiceが見つからないということです。

```
Nest can't resolve dependencies of the BusinessPartnerController (?). Please make sure that the argument BusinessPartnerService at index [0] is available in the RootTestModule context.
```
そこで、BusinessPartnerServiceをインポートし、`createTestingModule`メソッドにproviderとして渡すようにします。

```javascript
import { Test, TestingModule } from '@nestjs/testing';
import { BusinessPartnerService } from './business-partner.service'; //追加
import { BusinessPartnerController } from './business-partner.controller';

describe('BusinessPartnerController', () => {
  let controller: BusinessPartnerController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [BusinessPartnerController],
      providers: [BusinessPartnerService], //追加
    }).compile();

    ...
});

```

これでエラーがなくなりました。
ただし、この状態だとControllerのテストをしたいのに、テストがServiceに依存することになります。Controller単体のテストができることが望ましいので、BusinessPartnerServiceをモックの実装(` mockBusinessPartnerService`)に置き換えます。

```javascript
describe('BusinessPartnerController', () => {
  let controller: BusinessPartnerController;
  const mockBusinessPartnerService = {}; //モックのBusinessPartnerService:このあと実装

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [BusinessPartnerController],
      providers: [BusinessPartnerService],
    })
    //モックに置き換える
    .overrideProvider(BusinessPartnerService)
    .useValue(mockBusinessPartnerService)    
    .compile();
```

####Business Partner取得のテスト
Controllerの`getAllBusinessPartners`メソッドをテストします。テストすることはシンプルに、BusinessPartnerServiceの`getAllBusinessPartners`を呼べていることです。

```javascript:テスト対象の処理
  @Get()
  getAllBusinessPartners(): Promise<BusinessPartner[]> {
    return this.businessPartnerService.getAllBusinessPartners();
  }
```
` mockBusinessPartnerService`に`getAllBusinessPartners`メソッドを追加します。[`jest.fn().mockImplementation(value)`](https://jestjs.io/docs/mock-function-api#mockfnmockresolvedvaluevalue)はvalueに設定した値をPromiseで返すモックファンクションです。

```javascript
  //モックの返り値
  const mockBusinessPartners = [{
    businessPartner: "1003764",
    firstName: "John",
    lastName: "Doe"
  },
  {
    businessPartner: "1003765",
    firstName: "Jane",
    lastName: "Roe"
  }];
  const mockBusinessPartnerService = {
    //モックのメソッド実装
    getAllBusinessPartners: jest.fn().mockResolvedValue(mockBusinessPartners)
  };
```
以下でBusiness Partner取得のテストを追加します。[jest](https://jestjs.io/)の2つのメソッドを使ってテストをしています。

- [`expect(value1).toEqual(value2)`](https://jestjs.io/docs/expect#toequalvalue): value1とvalue2が同じプロパティを持っているかどうか確認するために使います。
- [`expect(value).toHaveBeenCalled()`](https://jestjs.io/docs/expect#tohavebeencalled): モックのファンクションが呼ばれたことを確認するために使います。

```javascript
  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  //追加
  it('should get business partners', async () => {
    const result = await controller.getAllBusinessPartners();
    expect(result).toEqual(mockBusinessPartners);
    expect(mockBusinessPartnerService.getAllBusinessPartners).toHaveBeenCalled();
  })
```

テストは以下のように正常終了します。

```
 PASS  src/business-partner/business-partner.controller.spec.ts
  BusinessPartnerController
    √ should be defined (15 ms)
    √ controller: should get business partners (4 ms)

---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   28.26 |        0 |   28.57 |   26.47 |                   
 src                             |       0 |        0 |       0 |       0 |                   
  app.controller.ts              |       0 |      100 |       0 |       0 | 1-10              
  app.module.ts                  |       0 |      100 |     100 |       0 | 1-12              
 PASS  src/business-partner/business-partner.controller.spec.ts (11.802 s)
  BusinessPartnerController
    √ should be defined (18 ms)
    √ should get business partners (4 ms)

---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   28.26 |        0 |   28.57 |   26.47 |                   
 src                             |       0 |        0 |       0 |       0 |                   
  app.controller.ts              |       0 |      100 |       0 |       0 | 1-10              
  app.module.ts                  |       0 |      100 |     100 |       0 | 1-12              
  app.service.ts                 |       0 |      100 |       0 |       0 | 1-6               
  main.ts                        |       0 |        0 |       0 |       0 | 1-8               
 src/business-partner            |      65 |      100 |   66.67 |   64.29 | 
  business-partner.controller.ts |     100 |      100 |     100 |     100 | 
  business-partner.module.ts     |       0 |      100 |     100 |       0 | 1-9
  business-partner.service.ts    |   83.33 |      100 |       0 |      75 | 7
---------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        17.02 s
Ran all test suites matching /business-partner.controller/i.

Watch Usage: Press w to show more.
```

####テスト対象のファイルを絞る
特定の機能のみテストしたいときは`Watch Usage: Press w to show more.`の後に`p`と打って、`pattern`の後にテスト対象のファイル名（またはその一部）を入力します。

```
Pattern Mode Usage
 › Press Esc to exit pattern mode.
 › Press Enter to filter by a filenames regex pattern.

 pattern › business-partner.controller
```

###Serviceのテスト
`business-partner.service.ts`の処理を`business-partner.service.spec.ts`でテストします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4e3ddacd-8245-5385-1870-3364660cdbe6.png)

Serviceの`getAllBusinessPartners`メソッドをテストします。ここでは、実際のBusiness Partnerサービスに接続してデータを取得できることを確認します。

```javascript:テスト対象の処理
  getAllBusinessPartners(): Promise<BusinessPartner[]> {
    return BusinessPartner.requestBuilder()
    .getAll()
    .select(
        BusinessPartner.BUSINESS_PARTNER,
        BusinessPartner.FIRST_NAME,
        BusinessPartner.LAST_NAME,
    )
    .filter(
        BusinessPartner.BUSINESS_PARTNER_CATEGORY.equals('1')
    )
    .execute({
      destinationName: 'MockServer',
    });
  }
```
`business-partner.service.spec.ts`を以下のように実装します。`mockBusinessPartners`には実際のサービスから返ってくる結果と同じものを設定します。

```javascript
import { Test, TestingModule } from '@nestjs/testing';
import { BusinessPartnerService } from './business-partner.service';

describe('BusinessPartnerService', () => {
  let service: BusinessPartnerService;

  const mockBusinessPartners = [
    {
      businessPartner: "1003764",
      firstName: "John",
      lastName: "Doe"
    },
    {
      businessPartner: "1003765",
      firstName: "Jane",
      lastName: "Roe"
    },
    {
      businessPartner: "1003766",
      firstName: "John",
      lastName: "Smith"
    },
    {
      businessPartner: "1003767",
      firstName: "Carla",
      lastName: "Coe"
    }
  ];

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [BusinessPartnerService],
    }).compile();

    service = module.get<BusinessPartnerService>(BusinessPartnerService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should get business partners', async () => {
    return expect(service.getAllBusinessPartners()).resolves.toEqual(mockBusinessPartners);
  });
});
```
この状態でテストを実行すると、以下のエラーになります。"MockServer"というDestinationが見つからないというエラーです。

```
 FAIL  src/business-partner/business-partner.service.spec.ts (9.678 s)
  BusinessPartnerService
    √ should be defined (13 ms)
    × should get business partners (92 ms)

  ● BusinessPartnerService › should get business partners

    expect(received).resolves.toEqual()

    Received promise rejected instead of resolved
    Rejected to value: [ErrorWithCause: Could not find a destination with name "MockServer"! Unable to execute request.] 
```
ローカル実行用のDestinationは[`.env`ファイルで設定](https://qiita.com/tami/items/2206a478a1ef9998e3b0#32-service%E3%81%AE%E5%AE%9F%E8%A3%85)していますが、テストの際には.envファイルを見てくれないようです。
このため、SAP Cloud SDKでは[@sap-cloud-sdktest-util](https://sap.github.io/cloud-sdk/api/1.45.0/modules/sap_cloud_sdk_test_util#sap-cloud-sdktest-util)というパッケージを使ってテスト用のDestinationを設定します。

####テスト用Destinationの設定
以下のステップでテスト用Destinationを設定します。

- @sap-cloud-sdk/test-utilから`mockTestDestination`をインポートし、`mockTestDestination()`を呼ぶ

```javascript
import { Test, TestingModule } from '@nestjs/testing';
import { BusinessPartnerService } from './business-partner.service';
import { mockTestDestination } from '@sap-cloud-sdk/test-util';

describe('BusinessPartnerService', () => {
  mockTestDestination('MockServer');
``` 
- プロジェクト直下の`systems.json`ファイルでDestinationを設定
    - URLには[Cloud Foudnryに登録した](https://qiita.com/tami/items/2206a478a1ef9998e3b0#41-destination%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E5%AE%9A)Business Partner ODataサービスの宛先を指定します。

```json:systems.json
{
  "systems": [{
    "alias": "MockServer",
    "uri": "https://odata-mock-server-forgiving-hartebeest.cfapps.eu10.hana.ondemand.com"
  }]
}
```

- プロジェクト直下の`credentials.json`ファイルで接続先の認証情報を設定（※認証がない場合でもusername、passwordを設定する必要がある）

```json:credentials.json
{
  "credentials": [{
    "alias": "MockServer",
    "username": "dummy",
    "password": "dummy"
  }]
}
```
上記の設定後、テストは正常終了します。

```
---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |   13.04 |        0 |   14.29 |   11.76 |                   
 src                             |       0 |        0 |       0 |       0 |                   
  app.controller.ts              |       0 |      100 |       0 |       0 | 1-10              
  app.module.ts                  |       0 |      100 |     100 |       0 | 1-12              
  app.service.ts                 |       0 |      100 |       0 |       0 | 1-6               
  main.ts                        |       0 |        0 |       0 |       0 | 1-8
 src/business-partner            |      30 |      100 |   33.33 |   28.57 | 
  business-partner.controller.ts |       0 |      100 |       0 |       0 | 1-13
  business-partner.module.ts     |       0 |      100 |     100 |       0 | 1-9
  business-partner.service.ts    |     100 |      100 |     100 |     100 | 
---------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        8.797 s
Ran all test suites matching /business-partner.service/i.
```

##e2e Test
testフォルダの下に`app.e.2-spec.ts`というファイルがありますが、これをコピーして`business-partner.e2e-spec.ts`を作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2cf9704c-8395-d1fc-e414-ef37f024f955.png)

モジュール名を`BusinessPartnerModule`に変更します。また、Destinationを利用するため、`mockTestDestination`をインポートして使用します。

```javascript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { BusinessPartnerModule } from './../src/business-partner'; //変更
import { mockTestDestination } from '@sap-cloud-sdk/test-util'; //追加

describe('BusinessPartnerController (e2e)', () => { //変更
  let app: INestApplication;
  mockTestDestination('MockServer'); //追加

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [BusinessPartnerModule], //変更
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  //ここにテストを書く
});

```

テストすることは、エンドポイント`/business-partner`を呼んだときに想定するレスポンスが返ってくるかということです。そこで、[Supertest](https://github.com/visionmedia/supertest)を使って以下のようにテストを書きます。
※mockBusinessPartnersはServiceのテストで使用したものと同じ内容で定義しておきます。

```javascript
  it('/business-partner (GET)', () => {
    return request(app.getHttpServer())
      .get('/business-partner')
      .expect(200)
      .expect(mockBusinessPartners);
  });
```
テストは以下のように正常終了します。

```
 PASS  test/business-partner.e2e-spec.ts (11.041 s)
  BusinessPartnerController (e2e)
    √ /business-partner (GET) (1931 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 | 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        12.126 s, estimated 16 s
Ran all test suites matching /business-partner/i.
```
##おわりに
今回はSAP Cloud SDKでテストを書く方法について、簡単な例でご紹介しました。POSTやPUT、DELETEのテストについては以下のリポジトリにテストを書いたものを格納していますので、参考にしてください。

https://github.com/miyasuta/my-sdk-project

##リファレンス

- [Jest](https://jestjs.io/docs/api)
- [Supertest](https://github.com/visionmedia/supertest)