AngularのテストでGoogle Maps API をモックする

Angular2以降の環境でGoogle Maps APIを使用している際にテストでモックするときにはまったのでメモ。

主にAngularにおけるユニットテストで、遅延ロードによってグローバルな名前空間に展開されるgoogle maps APIをモックする方法についてまとめる。いろいろと試しながらこの方法にたどり着いているので間違っていることがあるかも。

# 使用しているライブラリ
* @agm/core: google maps APIのロードがやりやすいことと、テスト時にロードしないようにするのが簡単なので。マップ自体をagmで書いていないのは今回は突っ込みなしで。
* @types/googlemaps: intellisense用途とモック作成時に参考にする。

# 手順概要
1. MapsAPILoader等のサービスはTestBedの構成時にモックする。load() でresolve()を呼ぶだけのPromiseを返すだけでよい。このモックをしていないとロードエラーになるか、ロードしたjsを読み込んでしまう。
1. 一番厄介な名前空間である`google.maps`はglobal変数に直接放り込む。モックする必要があるクラス等を`google.maps`の配下に作成しておき、`global.google = google;` のように代入するだけ。
1. テスト対象のtsファイルでモックをimportする。このとき、`import "google.maps.mock.ts";` のように型指定等はしないで記述する。

# サンプルコード

```typescript:some-map.component.ts
import { Component, OnInit, ElementRef } from "@angular/core";
import { } from "@types/googlemaps";
import { MapsAPILoader } from "@agm/core";
 
// これがないとビルド時やlintで怒られる
declare var google;
 
@Component({
  selector: "app-some-map",
  templateUrl: "./some-map.component.html",
  styleUrls: ["./some-map.component.scss"],
})
export class SomeMapComponent implements OnInit {
  map: google.maps.Map;   // ここにgoogle.maps.Mapオブジェクトを保管する予定
 
  constructor(
    private agm: MapsAPILoader,
    private elementRef: ElementRef,
) { }
 
  ngOnInit() {
    this.agm.load().then(() => this.initGoogleMap());
  }
 
  initGoogleMap() {
    // Mapオブジェクトを作成する。この処理をモックしたい。
    this.map = new google.maps.Map(this.elementRef.nativeElement.getElementsByClassName("some-map"), { /* options */ });
   // ...
  }
}
```

```typescript:google.maps.mock.ts
// この名前空間をグローバル領域に放り込む
export namespace google.maps {
 
  // 以降、必要なモックを実際の名前で作成
  export class Map {
    verifyMock = true;
    constructor(public opts: any) { }
  }
 
  export class Marker {
    verifyMock = true;
    constructor(public opts: any) { }
  }
 
  export enum MapTypeId {
    HYBRID,
    ROADMAP,
    SATELLITE,
    TERRAIN
  }
 
  // and other mocks ...
  // see @types/googlemaps for the declarations
}
 
// いったんanyに移さないとlintで怒られる。
const _global: any = global;
 
// 代入！！
_global.google = google;
```

```typescript:some-map.component.spec.ts
import { async, ComponentFixture, TestBed } from "@angular/core/testing";
import { MapsAPILoader } from "@agm/core";
import { } from "@types/googlemaps";
 
import { SomeMapComponent } from "./some-map.component";
 
// ここでインポートするとglobal.googleにモックが展開される
import "./google.maps.mock";
 
describe("SomeMapComponent", () => {
  let component: SomeMapComponent;
  let fixture: ComponentFixture<SomeMapComponent>;
 
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [SomeMapComponent],
      providers: [
        // ここでloadをモックする。結果を返さないとthen呼び出しでしぬので注意
        { provide: MapsAPILoader, useValue: { load() { return new Promise((resolve) => resolve()); } } },
      ],
    }).compileComponents();
  }));
 
  beforeEach(() => {
    fixture = TestBed.createComponent(SomeMapComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
 
  it("should create", () => {
    // コンポーネントの生成に成功していることを確認
    expect(component).toBeTruthy();
  });
 
  it("should init map", () => {
    component.initGoogleMap();
 
    // google.maps.Mapのオブジェクトが生成されていることを確認
    expect(component.map).toBeTruthy();
 
    // モックオブジェクトであることを確認
    const mock: any = component.map;
    expect(mock.verifyMock).toBe(true);
  });
});
```