iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 10
1
Modern Web

高效 Coding 術:Angular Schematics 實戰三十天系列 第 10

[高效 Coding 術:Angular Schematics 實戰三十天] Day09 - 撰寫支援 Angular 專案的 Schematics 的測試程式

在測試程式中模擬 Angular 專案環境

當我們開始將 Schematic 與 Angular 專案整合後,就會需要處理越來越多的細節。尤其是在測試時,會更需要去模擬各種情境。

就拿我們昨天做的 Schematic 來說,昨天的 Schematic 規格是:

當我們在 Angular 專案中使用這個 schematic 時,我們希望它會在正確的路徑下產生檔案。

正確的路徑的定義是,如果我們沒有指定專案,則它會在預設專案的路徑底下產生檔案;如果有指定專案,則會在該專案的路徑底下產生檔案。

因此,我們需要在測試中模擬 Angular 專案環境。

先來看看我們目前的測試程式:

import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import { strings } from '@angular-devkit/core';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('hello-world', () => {
  it('成功產出檔案,則檔名為/hello-leo-chen.component.ts', () => {
    const name = 'LeoChen';
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('hello-world', { name: name }, Tree.empty());

    const dasherizeName = strings.dasherize(name);
    const fullFileName = `/hello-${dasherizeName}.component.ts`;
    expect(tree.files).toContain(fullFileName);
    
    const fileContent = tree.readContent(fullFileName);
    expect(fileContent).toMatch(/hello-leo-chen/);
    expect(fileContent).toMatch(/HelloLeoChenComponent/);
  });
});

從上述的測試程式碼可以很明顯地看得出來,我們目前的測試碼並沒有辦法模擬 Angular 專案環境,那要怎麼辦呢?

筆者想先反問大家一件事,請問大家都怎麼建立 Angular 專案的?是不是都是用 Angular CLI 的 ng new 指令來產生專案的?

那在測試的時候,也一樣 ng new 一個就好啦!但該怎麼做呢?

平常當我們在使用 ng new 的時候,其實它會去使用 workspaceapplication 這兩個指令,所以我們也需要模仿它使用這兩個指令。

首先需要引入相關資源:

import { Schema as ApplicationOptions, Style } from '@schematics/angular/application/schema';
import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema';

接著先把參數準備好:

describe('hello-world', () => {
  const workspaceOptions: WorkspaceOptions = {
    name: 'workspace',          // 不重要的名字,隨便取,不影響測試結果
    newProjectRoot: 'projects', // 專案裡,所有 App 的根目錄,可以隨便取,驗證時會用到
    version: '0.1.0',           // 不重要的版號,隨便取,不影響測試結果
  };
  const appOptions: ApplicationOptions = {
    name: 'hello',              // 專案名稱
    inlineStyle: false,         // true or false 都可以,不影響測試結果
    inlineTemplate: false,      // true or false 都可以,不影響測試結果
    routing: false,             // true or false 都可以,不影響測試結果
    style: Style.Css,           // Css / Less / Sass / scss / styl 都可以,不影響測試結果
    skipTests: false,           // true or false 都可以,不影響測試結果
    skipPackageJson: false,     // true or false 都可以,不影響測試結果
  };
  it('成功產出檔案,則檔名為/hello-leo-chen.component.ts', () => {
    // 略
  });
});

然後呼叫 SchematicTestRunnerrunExternalSchematic 方法,並給予相關參數令其產生出 Angular 專案,並驗證結果:

describe('hello-world', () => {
  // 略
  it('成功在預設專案路徑底下產出檔案', () => {
    const options: HelloWorldSchema = { name: 'feature/Leo Chen' };
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let appTree = runner.runExternalSchematic(
      '@schematics/angular',
      'workspace',
      workspaceOptions
    );
    appTree = runner.runExternalSchematic(
      '@schematics/angular',
      'application',
      appOptions,
      appTree
    );
    const tree = runner.runSchematic('hello-world', options, Tree.empty());
    expect(tree.files).toContain('/projects/hello/src/app/feature/hello-leo-chen.component.ts');
  });
});

結果:

Imgur

為什麼會錯?!

這是因為 runSchematicrunExternalSchematic 這兩個函式是同步的,但 Schematics 在處理檔案的時候,在大多數情況下其實都是非同步的,因此 Schematics 在 v8.0.0 的時候就已經將這支 API 棄用了:

/**
  * @deprecated Since v8.0.0 - Use {@link SchematicTestRunner.runSchematicAsync} instead.
  * All schematics can potentially be async.
  * This synchronous variant will fail if the schematic, any of its rules, or any schematics
  * it calls are async.
  */
runSchematic<SchematicSchemaT>(schematicName: string, opts?: SchematicSchemaT, tree?: Tree): UnitTestTree;

如上所述,所以從 v8.0.0 之後,就改為使用 runSchematicAsyncrunExternalSchematicAsync 這兩支 API ,所以我們可以將其改成:

describe('hello-world', () => {
  // 略
  it('成功在預設專案路徑底下產出檔案', () => {
    const options: HelloWorldSchema = { name: 'feature/Leo Chen' };
    const runner = new SchematicTestRunner('schematics', collectionPath);
    runner.runExternalSchematicAsync(
      '@schematics/angular',
      'workspace',
      workspaceOptions
    ).pipe(
      mergeMap((tree) => {
        return runner.runExternalSchematicAsync(
          '@schematics/angular',
          'application',
          appOptions,
          tree
        );
      }),
      mergeMap((tree) => runner.runSchematicAsync('hello-world', options, tree))
    ).subscribe((tree) => {
      expect(tree.files).toContain('/projects/hello/src/app/feature/hello-leo-chen.component.ts');
    });
  });
});

Async & Await

雖然筆者滿喜歡用 RxJS ,但個人覺得在測試裡用有點殺雞用牛刀,而且乍看之下有種測試程式碼比原本的程式碼還要多的感覺,沒有那麼適當。

所以筆者再分享一招給大家,那就是使用 asyncawait

關於 asyncawait 的知識,可以參考 ES7 Async Await 聖經鐵人賽:JavaScript Await 與 Async 這兩篇文章

筆者覺得 asyncawait 非常適合在測試程式裡使用,畢竟一般不管是自己在 Coding 或是看別人的 Code 的時候,還是會比較習慣由上而下、由左至右地的方式。

話不多說,立馬來使用 asyncawait 改寫:

describe('hello-world', () => {
  // 略
  it('成功在預設專案路徑底下產出檔案', async () => {
    const options: HelloWorldSchema = { name: 'feature/Leo Chen' };
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'workspace',
      workspaceOptions
    ).toPromise();
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'application',
      appOptions,
      appTree
    ).toPromise();
    appTree = await runner.runSchematicAsync('hello-world', options, appTree).toPromise();
    expect(appTree.files).toContain('/projects/hello/src/app/feature/hello-leo-chen.component.ts');
  });
});

如何?是不是更直觀了些?

除了預設專案的情境之外,我們也可以再測試另一個專案的情境,看看我們是不是真的可以指定專案路徑來產生檔案。

首先先新增另一組 it ,並依樣畫葫蘆地加上測試程式碼:

// 略
describe('hello-world', () => {
  // 略
  it('成功在預設專案路徑底下產出檔案', async () => {
    // 略
  });
  it('成功在 "world" 專案路徑底下產出檔案', async () => {
    const options: HelloWorldSchema = { name: 'feature/Leo Chen', project: 'world' };
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'workspace',
      workspaceOptions
    ).toPromise();
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'application',
      appOptions,
      appTree
    ).toPromise();
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'application',
      { ...appOptions, name: 'world' },
      appTree
    ).toPromise();
    appTree = await runner.runSchematicAsync('hello-world', options, appTree).toPromise();
    expect(appTree.files).toContain('/projects/world/src/app/feature/hello-leo-chen.component.ts');
  });
});

但這樣會有太多重複的程式碼,重構一下:

describe('hello-world', () => {
  const runner = new SchematicTestRunner('schematics', collectionPath);
  const workspaceOptions: WorkspaceOptions = {
    name: 'workspace',
    newProjectRoot: 'projects',
    version: '6.0.0',
  };
  const appOptions: ApplicationOptions = {
    name: 'hello',
    inlineStyle: false,
    inlineTemplate: false,
    routing: false,
    style: Style.Css,
    skipTests: false,
    skipPackageJson: false,
  };
  const defalutOptions: HelloWorldSchema = { 
    name: 'feature/Leo Chen' 
  };
  
  let appTree: UnitTestTree;

  beforeEach(async () => {
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'workspace',
      workspaceOptions
    ).toPromise();
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'application',
      appOptions,
      appTree
    ).toPromise();
  });

  it('成功在預設專案路徑底下產出檔案', async () => {
    const options = { ...defalutOptions };
    const tree = await runner.runSchematicAsync('hello-world', options, appTree).toPromise();
    expect(tree.files).toContain('/projects/hello/src/app/feature/hello-leo-chen.component.ts');
  });
  it('成功在 "world" 專案路徑底下產出檔案', async () => {
    appTree = await runner.runExternalSchematicAsync(
      '@schematics/angular',
      'application',
      { ...appOptions, name: 'world' },
      appTree
    ).toPromise();
    const options = { ...defalutOptions, project: 'world' };
    const tree = await runner.runSchematicAsync('hello-world', options, appTree).toPromise();
    expect(tree.files).toContain('/projects/world/src/app/feature/hello-leo-chen.component.ts');
  });
});

雖然還有很多地方可以再重構,不過筆者覺得,在 it 裡還是要能看得出基本的 3A原則 會比較好。

3A 原則 指的是:

  1. Arrange - 初始化目標物件、相依物件、方法參數、預期結果,或是預期與相依物件的互動方式等等。
  2. Act - 呼叫目標物件的方法。
  3. Assert - 驗證結果是否符合預期。

結果:

Imgur

本日結語

今天的程式碼:https://github.com/leochen0818/angular-schematics-30days-challenge/tree/day09

今天是本系列文第三次寫測試了,大家是否有越來越熟悉了呢?!與其放綠乖乖保平安,還不如多寫些測試案例並把它都變綠燈來得更加實在!

目前為了讓大家逐漸熟悉寫測試,所以都會採用先開發、後測試的方式來撰寫文章;後續會慢慢調整成先寫測試再開發的方式,畢竟通常都是先有規格,才會開始開發。特例就算了(笑)。

到目前為止,我們已經學會了如何新增程式碼,之後要開始學習如何編輯程式碼,為此,筆者明天將分享給大家一個非常強大的 Libaray 給大家,敬請期待。

參考資料


上一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day08 - 增加對 Angular 專案的支援
下一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day10 - 與 TypeScript Compiler API 的初次接觸
系列文
高效 Coding 術:Angular Schematics 實戰三十天32

尚未有邦友留言

立即登入留言