getComputedStyle()がJestで期待通りに動作しない件について

# 概要

Jestでテストを行った際にSVG要素の属性値を取得できなかったため、モックで対処した件の覚書。

# ソースコード

```javascript:App.js
import React from 'react';

class App extends React.Component {
  getWidthFromDiv() {
    const computedStyle = window.getComputedStyle(this.refDiv);
    const widthInString = computedStyle.getPropertyValue('width');
    const widthInInt = parseInt(widthInString);

    console.log("div.width:", widthInInt);
    console.log("window.getComputedStyle(div):", computedStyle);
    
    return widthInInt;
  }
  getRFromCircle() {
    const computedStyle = window.getComputedStyle(this.refCircle);
    const rInString = computedStyle.getPropertyValue('r');
    const rInInt = parseInt(rInString);

    console.log("circle.r:", rInInt);
    console.log("window.getComputedStyle(div):", computedStyle);
    
    return rInInt;
  }
  render() {
    return (
      <div>
        <div
          ref={(div) => {this.refDiv = div}}
          style={{
            width: 100,
            height: 200,
          }}
        >
          Dummy
        </div>
        <svg xmlns="http://www.w3.org/2000/svg">
          <circle 
            ref={(circle) => {this.refCircle = circle}} 
            cx="100" cy="50" r="30"
          />
        </svg>
      </div>
    );
  }
}

export default App;
```

```javascript:App.test.js
import React from 'react';
import { configure, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import App from './App';

configure({ adapter: new Adapter() });

it('div', () => {
  const app = mount(<App />);
  const appInstance = app.instance();
  expect(appInstance.getWidthFromDiv()).toBe(100);
});

it('circle', () => {
  const app = mount(<App />);
  const appInstance = app.instance();
  expect(appInstance.getRFromCircle()).toBe(30);
});
```

# テスト結果

```bash
 FAIL  src/App.test.js
  ✓ div (75ms)
  ✕ circle (13ms)

  ● circle

    expect(received).toBe(expected) // Object.is equality

    Expected: 30
    Received: NaN

      15 |   const app = mount(<App />);
      16 |   const appInstance = app.instance();
    > 17 |   expect(appInstance.getRFromCircle()).toBe(30);
         |^
      18 | });
      19 |

      at Object.toBe (src/App.test.js:17:40)

  console.log src/App.js:9
    div.width: 100

  console.log src/App.js:10
    window.getComputedStyle(div): CSSStyleDeclaration {
      '0': 'display',
      '1': 'width',
      '2': 'height',
      _values: { display: 'block', width: '100px', height: '200px' },
      _importants: { display: '', width: '', height: '' },
      _length: 3,
      _onChange: [Function] }

  console.log src/App.js:19
    circle.r: NaN

  console.log src/App.js:20
    window.getComputedStyle(div): CSSStyleDeclaration {
      _values: {},
      _importants: {},
      _length: 0,
      _onChange: [Function] }

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        0.749s, estimated 1s
Ran all test suites related to changed files.

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

<img width="1002" alt="Screen Shot 2018-10-15 at 2.06.42.png" src="https://qiita-image-store.s3.amazonaws.com/0/286440/5e7cc7af-4b68-9415-c8d5-d626701a5a26.png">

ブラウザ上では描画、値の取得ともに成功しているが、テスト時にはCircle要素の属性`r`の値はNaNとなっている。出力結果には、Circle要素に`getComputedStyle()`を実行した時に属性値が取得できていないことが示されているため、Jest特有の事象と考えられる。

## Div要素の属性値は取得できている

```bash
  console.log src/App.js:10
    window.getComputedStyle(div): CSSStyleDeclaration {
      '0': 'display',
      '1': 'width',
      '2': 'height',
      _values: { display: 'block', width: '100px', height: '200px' },
      _importants: { display: '', width: '', height: '' },
      _length: 3,
      _onChange: [Function] }
```

## Circle要素の属性値は取得できていない

```bash
  console.log src/App.js:20
    window.getComputedStyle(div): CSSStyleDeclaration {
      _values: {},
      _importants: {},
      _length: 0,
      _onChange: [Function] }
```

# ワークアラウンド

原因追求はのちに行うとして、現時点で関数`getComputedStyle()`から期待値を取得するには、関数をモックする必要がある。以下、Sinonを使って実装した。

```javascript:App.js
import React from 'react';

class App extends React.Component {
  getWidthFromDiv() {
    const computedStyle = window.getComputedStyle(this.refDiv);
    const widthInString = computedStyle.getPropertyValue('width');
    const widthInInt = parseInt(widthInString);
    return widthInInt;
  }
  getRFromCircle() {
    const rInString = this.getR();
    const rInInt = parseInt(rInString);
    return rInInt;
  }
  getR() {
    const computedStyle = window.getComputedStyle(this.refCircle);
    const rInString = computedStyle.getPropertyValue('r');
    return rInString;
  }
  render() {
    return (
      <div>
        <div
          ref={(div) => {this.refDiv = div}}
          style={{
            width: 100,
            height: 200,
          }}
        >
          Dummy
        </div>
        <svg xmlns="http://www.w3.org/2000/svg">
          <circle 
            ref={(circle) => {this.refCircle = circle}} 
            cx="100" cy="50" r="30"
          />
        </svg>
      </div>
    );
  }
}

export default App;
```

```javascript:App.test.js
import React from 'react';
import { configure, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import sinon from 'sinon';
import App from './App';

configure({ adapter: new Adapter() });

const stub = sinon.stub(App.prototype, 'getR');
stub.returns("30px");

it('div', () => {
  const app = mount(<App />);
  const appInstance = app.instance();
  expect(appInstance.getWidthFromDiv()).toBe(100);
});

it('circle', () => {
  const app = mount(<App />);
  const appInstance = app.instance();
  expect(appInstance.getRFromCircle()).toBe(30);
});
```

```bash
 PASS  src/App.test.js
  ✓ div (38ms)  ✓ circle (2ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.655s, estimated 1s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

```

## モック対象の外出し

後述の理由により`getComputedStyle()`はモックできないため、その返り値を用いるステートメントも正常な動作を期待できない。このため、以下の2行を別の関数へ外出しする。

```javascript:App.js（外出し対象）
    const computedStyle = window.getComputedStyle(this.refCircle);
    const rInString = computedStyle.getPropertyValue('r');
```

外出し前後のソースコード（抜粋）は以下の通り。なお、コンソール出力の行は削除している。

```javascript:App.js（外出し前）
  getRFromCircle() {
    const computedStyle = window.getComputedStyle(this.refCircle);
    const rInString = computedStyle.getPropertyValue('r');
    const rInInt = parseInt(rInString);
    return rInInt;
  }
```

```javascript:App.js（外出し後）
  getRFromCircle() {
    const rInString = this.getR();
    const rInInt = parseInt(rInString);
    return rInInt;
  }
  getR() {
    const computedStyle = window.getComputedStyle(this.refCircle);
    const rInString = computedStyle.getPropertyValue('r');
    return rInString;
  }
```

新たに作成した関数`getR()`は本環境において、期待通りに動作すれば`"30px"`を返すだけの関数である。ここを問答無用で`"30px"`を返すようモックすると、テストを期待通りにパスする。

```javascript:App.test.js（モックのみ抜粋）
const stub = sinon.stub(App.prototype, 'getR');
stub.returns("30px");
```

## `getComputedStyle()`をモックできない理由

`getComputedStyle()`の戻り値はCSSStyleDeclaration型である。この型は**特定の条件によってのみ得られる読み取り専用の型[^1]**であり、この型の変数を自作することはできない。ゆえにCSSStyleDeclaration型の戻り値は実現できず、`getComputedStyle()`をモックすることは不可能である。

本来、テストのためのソースコード変更はするべきではないが、テスト出来ない箇所をテストしないという訳にもいかない。そのため、`getComputedStyle()`を利用する最小限の範囲を別の関数に外出しし、そちらをモックすることでテストを実現した。

[^1]: https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration#Summary