Lightning Web コンポーネントをJestでテストする

# 概要
LWCの開発ではjavascriptのテストは必須ではありませんが、今後テスト駆動開発等を視野に入れJestを触ってみました。
Javascriptのテスト自体初めての試みとなるのでご指摘等あれば是非お願い致します。
また、今回Jestの導入方法に関しては触れていませんので各自Trail等で補完してください。

# 環境
sfdx-cli v7.99.0
node v12.20.1
npm v6.14.10
sfdx-lwc-jest

# 実装イメージ
下記のような商談名・完了予定日・フェーズを入力し、新規の商談を作成した上で、現在の商談のリストを表示する「jestTest」という名称のLWCを作成しました。
今回はこちらのLWCに対して

- 商談のリストを表示する機能に対するテスト
- 商談を登録する機能に対するテスト

の上記二つのテストを実装してみたいと思います。
尚、APEX側のテストは今回実装致しません。
![demo](https://gyazo.com/e17fab28ae262cf928ba5c637702709e/raw)

# LWC実装コード

```html:jestTest.html
<template>
    <lightning-card title="商談リスト">
        <div class="slds-p-bottom_large slds-p-left_large">
            <lightning-record-edit-form object-api-name="Opportunity">
                <lightning-messages></lightning-messages>
                <lightning-input-field field-name="Name" onchange={handleChangeName}>
                    </lightning-input-field>
                <lightning-input-field field-name="CloseDate" onchange={handleChangeDate}>
                    </lightning-input-field>
                <lightning-input-field field-name="StageName" onchange={handleChangeStage}>
                    </lightning-input-field>
                <lightning-button type="submit"
                                  label="作成"
                                  onclick={handleClickSubmit}>
                </lightning-button>
            </lightning-record-edit-form>
            </div>
        <lightning-datatable
            key-field="id"
            data={data}
            columns={columns}>
        </lightning-datatable>
    </lightning-card>
</template>
```

```js:jestTest.js
import { LightningElement, api, wire, track } from 'lwc';
import { createRecord } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { refreshApex } from '@salesforce/apex';
import getOpportunities from '@salesforce/apex/LwcJestTest.GetRecords'
import OPPORTUNITY from '@salesforce/schema/Opportunity';
import NAME_FIELD from '@salesforce/schema/Opportunity.Name';
import CLOSEDATE_FIELD from '@salesforce/schema/Opportunity.CloseDate';
import STAGE_FIELD from '@salesforce/schema/Opportunity.StageName';
import ACCOUNT_ID_FIELD from '@salesforce/schema/Opportunity.AccountId';

const action = [
    { label: '削除', name: 'delete'}
]
const columns = [
    { label: '商談名', fieldName: 'Name'},
    { label: '完了予定日', fieldName: 'CloseDate'},
    { label: 'フェーズ', fieldName: 'StageName'},
]

export default class JestTest extends LightningElement {
    @api recordId;
    @track data;
    @track result;

    @wire(getOpportunities, {accountId: '$recordId'})
    getOpportunities(result){
        this.result = result;
        if(result.data){
            this.data = result.data;
        }else if(result.error){
            console.log(result.error);
        }
    }

    columns = columns;   
    objectApiName = OPPORTUNITY; 
    name;
    date;
    stage;

    handleChangeName(e){
        this.name = e.detail.value;
    }
    handleChangeDate(e){
        this.date = e.detail.value;
    }
    handleChangeStage(e){
        this.stage = e.detail.value;
    }
    handleClickSubmit(){
        const recordInput = {
            apiName: OPPORTUNITY.objectApiName,
            fields: {
                [NAME_FIELD.fieldApiName]: this.name,
                [CLOSEDATE_FIELD.fieldApiName]: this.date,
                [STAGE_FIELD.fieldApiName]: this.stage,
                [ACCOUNT_ID_FIELD.fieldApiName]: this.recordId
            }
        }
        createRecord(recordInput)
            .then(() => {
                refreshApex(this.result);
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Success',
                        message: 'Created',
                        variant: 'success',
                    }),
                );
            })
            .catch(error => {
                console.log(error);
            });
    }
}
```

```apex:LwcJestTest.cls
public with sharing class LwcJestTest {
    @AuraEnabled(cacheable=true)
    public static List<Opportunity> GetRecords(Id accountId) {
        return [
            SELECT Id, Name, CloseDate, StageName
            FROM Opportunity
            WHERE AccountId = :accountId
        ];
    }
}
```

# Jestテストコード

```js:jestTest.test.js
import { createElement } from 'lwc';
import { registerLdsTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
import getOpportunities from '@salesforce/apex/LwcJestTest.GetRecords'
import JestTest from '../jestTest';

const ACCOUNT_ID = '0015h000008wTyvAAE';
const mockGetRecord = require('./data/getRecord.json');
const getRecordAdapter = registerLdsTestWireAdapter(getOpportunities);

describe('c-jest-test', () => {
    afterEach(() => {
        while(document.body.firstChiled) {
            document.body.removeChild(document.body.firstChild);
        }
    });
    it('renders opportunity details', () => {
        const element = createElement('c-jest-test', {
            is: JestTest
        });
        document.body.appendChild(element);
        getRecordAdapter.emit(mockGetRecord);

        return Promise.resolve().then(() => {
            const detailElement = element.shadowRoot.querySelector('lightning-datatable');
            expect(detailElement.length).toBe(getRecordAdapter.length);
        })
    })

    it('submit opporunity', () => {
        const element = createElement('c-jest-test', {
            is: JestTest
        });
        element.recordId = ACCOUNT_ID;
        element.name = 'Test Opportunity';
        element.date = '2021-05-01';
        element.stage = 'Prospecting';
        document.body.appendChild(element);

        const showToastHandler = jest.fn();
        element.addEventListener('lightning__showtoast', showToastHandler);
        const submitElement = element.shadowRoot.querySelector('lightning-button');
        submitElement.click();
        
        return Promise.resolve().then(() => {
            expect(showToastHandler).toHaveBeenCalled();
        })
    })
})
```

```json:getRecord.json
{
    "Id": "0065h000005GlkgAAC", 
    "Name": "Test Opportunity", 
    "CloseDate": "2021-05-01", 
    "StageName": "Prospecting"
}
```

# 解説
`jestTest`ディレクトリ下に`__tests__`ディレクトリを作成し、その中に`jestTest.test.js`という名称のファイルを生成します。
`jestTest.test.js`は大枠となるテストスイートと、二つのテストブロックで構成されています。

まず大枠となるテストスイートを見ていきます。

```js
describe('c-jest-test', () => {
    afterEach(() => {
        while(document.body.firstChiled) {
            document.body.removeChild(document.body.firstChild);
        }
    });
    ...
})
```

このテストスイートの`afterEach()`はJestのクリーンアップメソッドとなっています。
このテストスイート内の各テスト後に`afterEach()`が実行され、DOMがリセットされる事で他のテストに影響を与えないようにします。

次に商談のリストを表示する機能に対するテストブロックを見ていきます。

```js
it('renders opportunity details', () => {
    const element = createElement('c-jest-test', {
        is: JestTest
    });
    document.body.appendChild(element);
    getRecordAdapter.emit(mockGetRecord);

    return Promise.resolve().then(() => {
        const detailElement = element.shadowRoot.querySelector('lightning-datatable');
        expect(detailElement.length).toBe(getRecordAdapter.length);
    })
})
```

まず、インポートした`createElement`メソッドを使用し、テストするコンポーネントのインスタンスを生成します。
次に、`emit()`メソッドを使用して、予め生成していたApexワイヤアダプタに期待されたjsonデータをモックします。
モックデータとして、`__tests__`ディレクトリ下の`data`ディレクトリに`getRecord.json`ファイルを作成し、下記のように定義しインポートします。

```json:getRecord.json
{
    "Id": "0065h000005GlkgAAC", 
    "Name": "Test Opportunity", 
    "CloseDate": "2021-05-01", 
    "StageName": "Prospecting"
}
```

最後に、モックしたデータと実際にlightning-datatableに表示されているデータ数に相違が無ければテストを通します。

```js
return Promise.resolve().then(() => {
    const detailElement = element.shadowRoot.querySelector('lightning-datatable');
    expect(detailElement.length).toBe(getRecordAdapter.length);
})
```

次に商談を登録する機能に対するテストブロックを見ていきます。

```js
it('submit opporunity', () => {
    const element = createElement('c-jest-test', {
        is: JestTest
    });
    element.recordId = ACCOUNT_ID;
    element.name = 'Test Opportunity';
    element.date = '2021-05-01';
    element.stage = 'Prospecting';
    document.body.appendChild(element);

    const showToastHandler = jest.fn();
    element.addEventListener('lightning__showtoast', showToastHandler);
    const submitElement = element.shadowRoot.querySelector('lightning-button');
    submitElement.click();
    
    return Promise.resolve().then(() => {
        expect(showToastHandler).toHaveBeenCalled();
    })
})
```

実際にレコードを生成する部分をテストする必要があるので、生成したコンポーネントのインスタンスの変数に値を定義していきます。
本来であればここでlightning-inputに値が入力される部分に対してもテストする必要がありますが、今回はシンプルな動作になるので割愛します。

今回はレコードの作成成功時にトーストが表示されるように設計しているので、正常にトースト表示が呼び出されているかどうかでレコードの作成成功を判別していこうと思います。

```js
const showToastHandler = jest.fn();
element.addEventListener('lightning__showtoast', showToastHandler);
```

上記のようにまずモック関数を作成した上で、コンポーネントインスタンスに対してshowtoastイベントを定義します。

```js
const submitElement = element.shadowRoot.querySelector('lightning-button');
submitElement.click();

return Promise.resolve().then(() => {
    expect(showToastHandler).toHaveBeenCalled();
})
```

そして、subtmiボタンがクリックされた時に先ほど定義したshowtoastが呼び出されていればテストを通します。

上記二つを実装後、`npm run test:unit`コマンドを実行しテストが通っていれば完了です。

# 最後に
近年、javascriptのテストフレームワークとしてJestの採用も増えているとの事だったので、LWCという取っつきやすい部分から足がかりを得られたのは大きいですね。
Trailの解説もとても分かりやすいので是非取り組んで見て下さい。