使用 Jasmine 和 Karma 测试 Angular(第 1 部分)

我们的目标

在本教程中,我们将为一家虚构公司构建和测试员工目录。这个目录将有一个视图来显示我们所有的用户以及另一个视图作为个人用户的个人资料页面。在本教程的这一部分中,我们将专注于构建将用于这些用户的服务及其测试。

在接下来的教程中,我们将使用Pokeapi用用户最喜欢的 Pokemon 的图像填充用户配置文件页面,并学习如何测试发出 HTTP 请求的服务。

你应该知道的

本教程的主要重点是测试,因此我的假设是您熟悉使用 TypeScript 和 Angular 应用程序。因此,我不会花时间解释什么是服务以及如何使用它。相反,我会在我们完成测试时为您提供代码。

为什么要测试?

从个人经验来看,测试是防止软件缺陷的最佳方式。过去我曾在许多团队中更新一小段代码,开发人员手动打开他们的浏览器或 Postman 以验证它是否仍然有效。这种方法(手动 QA)是一场灾难。

测试是防止软件缺陷的最佳方式。

随着功能和代码库的增长,手动 QA 变得更加昂贵、耗时且容易出错。如果某个特性或功能被移除,每个开发人员是否都记得它的所有潜在副作用?所有开发人员都以相同的方式手动测试吗?可能不会。

我们测试代码的原因是为了验证它的行为是否符合我们的预期。作为此过程的结果,您会发现您自己和其他开发人员拥有更好的功能文档,以及您的 API 的设计帮助。

为什么是业力?

Karma 是 AngularJS 团队努力使用现有工具测试他们自己的框架功能的直接产物。因此,他们制作了 Karma 并将其转换为 Angular,作为使用 Angular CLI 创建的应用程序的默认测试运行器。

除了可以很好地使用 Angular 之外,它还为您提供了根据工作流程定制 Karma 的灵活性。这包括在各种浏览器和设备(例如手机、平板电脑,甚至是像 YouTube 团队这样的PS3)上测试您的代码的选项

Karma 还为您提供了用其他测试框架(如MochaQUnit)替换 Jasmine 的选项,或者与各种持续集成服务(如JenkinsTravisCICircleCI)集成

除非您添加一些额外的配置,否则您与 Karma 的典型交互将ng test在终端窗口中运行

为什么是茉莉花?

Jasmine 是一个行为驱动的开发框架,用于测试与 Karma 配合得很好的 JavaScript 代码。与 Karma 类似,它也是Angular 文档中推荐的测试框架,因为它是使用 Angular CLI 为您设置的。Jasmine 也是无依赖的,不需要 DOM。

就功能而言,我喜欢 Jasmine 几乎内置了测试所需的一切。最显着的例子是间谍。间谍允许我们“监视”一个函数并跟踪有关它的属性,例如它是否被调用、调用了多少次以及调用了哪些参数。对于像 Mocha 这样的框架,间谍不是内置的,需要将它与像 Sinon.js 这样的单独库配对。

好消息是测试框架之间的转换成本相对较低,语法差异小到 JasminetoEqual()和 Mocha 的to.equal().

一个简单的测试示例

想象一下,你有一个名叫 Adder 的外星仆人,无论你走到哪里都会跟着你。除了做一个可爱的外星伙伴之外,Adder 真的只能做一件事,就是将两个数字相加。

为了验证 Adder 将两个数字相加的能力,我们可以生成一组测试用例,看看 Adder 是否为我们提供了正确的答案。

在 Jasmine 中,这将从所谓的“套件”开始,它通过调用函数对一组相关的测试进行分组describe

// A Jasmine suite
describe('Adder', () => {

});

从这里我们可以为 Adder 提供一组测试用例,例如两个正数 (2, 4)、一个正数和一个零 (3, 0)、一个正数和一个负数 (5, -2),以及很快。

在 Jasmine 中,这些被称为“规范”,我们通过调用函数创建it它,并传递一个字符串来描述正在测试的功能。

describe('Adder', () => {
  // A jasmine spec
  it('should be able to add two whole numbers', () => {
    expect(Adder.add(2, 2)).toEqual(4);
  });

  it('should be able to add a whole number and a negative number', () => {
    expect(Adder.add(2, -1)).toEqual(1);
  });

  it('should be able to add a whole number and a zero', () => {
    expect(Adder.add(2, 0)).toEqual(2);
  });
});

在每个规范中,我们调用expect并提供所谓的“实际”——我们实际代码的调用点。在期望或 之后expect,是链接的“匹配器”函数,例如toEqual,测试开发人员提供了被测试代码的预期输出

除了 toEqual 之外,还有许多其他匹配器可供我们使用。您可以在Jasmine 的文档 中查看完整列表

我们的测试不关心Adder如何得出答案。我们只关心 Adder 提供给我们的答案。据我们所知,这可能是 Adder 对add.

function add(first, second) {
  if (true) { // why?
    if (true) { // why??
      if (1 === 1) { // why?!?1
        return first + second;
      }
    }
  }
}

换句话说,我们只关心 Adder 的行为是否符合预期——我们不关心 Adder 的实现。

这就是测试驱动开发 (TDD) 等实践如此强大的原因。您可以首先为函数及其预期行为编写测试并使其通过。然后,一旦它通过,您就可以将您的函数重构为不同的实现,如果测试仍在通过,您就知道即使使用不同的实现,您的函数仍然按照测试中指定的方式运行。加法器的add函数就是一个很好的例子!

角度设置

我们将首先使用 Angular CLI 创建我们的新应用程序。

ng new angular-testing --routing

由于我们将在此应用程序中拥有多个视图,因此我们使用该--routing标志,因此 CLI 会自动为我们生成一个路由模块。

从这里我们可以通过移动到新angular-testing目录并运行应用程序来验证一切是否正常工作

cd angular-testing
ng serve -o

您还可以验证应用程序的测试当前是否处于通过状态。

ng test

添加主页

在创建使用用户填充我们的主页的服务之前,我们将首先创建主页。

ng g component home

现在我们的组件已经创建,我们可以将路由模块的 ( app-routing.module.ts) 根路径更新为HomeComponent.

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';

const routes: Routes = [
  { path: '', component: HomeComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

如果尚未运行该应用程序,则运行该应用程序,您现在应该看到“家庭作业!” app.component.html在 CLI 创建的默认模板下方

删除 AppComponent 测试

由于我们不再需要 的默认内容AppComponent,让我们通过删除一些不必要的代码来更新它。

首先,删除所有内容,app.component.html以便仅router-outlet保留指令。

<router-outlet></router-outlet>

在 中app.component.ts,您还可以删除该title属性。

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent { }

最后,您可以app.component.spec.ts通过删除之前在 中的某些内容的两个测试来更新测试app.component.html

import { async, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule
      ],
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));
  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
});

测试 Angular 服务

现在我们的主页已经设置好了,我们可以开始创建一个服务,用我们的员工目录填充这个页面。

ng g service services/users/users

在这里,我们users在一个新services/users目录中创建了我们的服务,以使我们的服务远离app可能会很快变得混乱的默认目录。

现在我们的服务已创建,我们可以对测试文件进行一些小的更改services/users/users.service.spec.ts

我个人发现在其中注入依赖项it()有点重复且难以阅读,因为它是在我们测试文件的默认脚手架中完成的,如下所示:

it('should be created', inject([TestService], (service: TestService) => {
  expect(service).toBeTruthy();
}));

通过一些小的更改,我们可以将其移动到beforeEach从每个it.

import { TestBed } from '@angular/core/testing';
import { UsersService } from './users.service';

describe('UsersService', () => {
  let usersService: UsersService; // Add this

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [UsersService]
    });

    usersService = TestBed.get(UsersService); // Add this
  });

  it('should be created', () => { // Remove inject()
    expect(usersService).toBeTruthy();
  });
});

在上面的代码中,TestBed.configureTestingModule({})使用UsersServiceset in 设置我们要测试的服务providers然后我们将服务注入到我们的测试套件中,使用TestBed.get()我们想要测试的服务作为参数。我们将返回值设置为我们的局部usersService变量,这将允许我们在测试中与此服务进行交互,就像在组件中一样。

现在我们的测试设置已经重组,我们可以为一个all将返回用户集合的方法添加一个测试

import { of } from 'rxjs'; // Add import

describe('UsersService', () => {
  ...

  it('should be created', () => {
    expect(usersService).toBeTruthy();
  });

  // Add tests for all() method
  describe('all', () => {
    it('should return a collection of users', () => {
      const userResponse = [
        {
          id: '1',
          name: 'Jane',
          role: 'Designer',
          pokemon: 'Blastoise'
        },
        {
          id: '2',
          name: 'Bob',
          role: 'Developer',
          pokemon: 'Charizard'
        }
      ];
      let response;
      spyOn(usersService, 'all').and.returnValue(of(userResponse));

      usersService.all().subscribe(res => {
        response = res;
      });

      expect(response).toEqual(userResponse);
    });
  });
});

在这里,我们为all返回用户集合的期望添加了一个测试我们声明一个userResponse变量设置为我们的服务方法的模拟响应。然后我们使用该spyOn()方法来监视usersService.all并链接.returnValue()返回我们userResponse包装它的模拟变量,of()以将该值作为可观察对象返回。

使用我们的间谍集,我们像在组件中一样调用我们的服务方法,订阅可观察对象,并将其返回值设置为response

最后,我们添加response将设置为服务调用的返回值的期望值userResponse

为什么要嘲笑?

在这一点上,很多人会问,“我们为什么要嘲笑回应?” 为什么我们为我们的测试提供一个userResponse我们自己创建的返回值,以手动设置从我们的服务返回的内容?服务调用不应该返回来自服务真实响应,无论是硬编码的用户集还是来自 HTTP 请求的响应?

这是一个非常合理的问题,当您第一次开始测试时,您可能很难理解这个问题。我发现这个概念最容易用真实世界的例子来说明。

想象一下,您拥有一家餐厅,现在是开业前一天晚上。您召集所有为餐厅“试运行”而雇用的人。你邀请了几个朋友进来,假装他们是会坐下来点餐的顾客。

在您的试运行中,实际上不会提供任何菜肴。您已经与您的厨师合作过,并且对他们能够正确制作菜肴感到满意。在此测试运行中,您要测试从客户订购菜品到服务员将菜品送到厨房,然后服务员完成厨房对顾客的响应的转换。厨房的这种反应可能是几种选择之一。

  1. 饭做好了。
  2. 饭菜耽误了。
  3. 饭不能做。我们用完了这道菜的原料。

如果饭菜做好了,服务员就会把饭菜送到顾客手上。但是,如果餐点迟到或无法制作,服务员将不得不回到顾客面前,道歉,并可能要求提供第二道菜。

在我们的测试运行中,当我们想要测试前端(服务员)满足从后端(厨房)收到的请求的能力时,实际创建膳食是没有意义的。更重要的是,如果我们想测试我们的服务员实际上可以在用餐延迟或无法制作的情况下向顾客道歉,我们实际上会等到我们的厨师太慢或我们的食材用完才对这些情况进行测试可以确认。出于这个原因,我们将“模拟”后端(厨房)并为服务员提供我们想要测试的任何场景。

同样在代码中,当我们测试各种场景时,我们实际上并没有点击 API。我们模拟我们可能期望收到的响应并验证我们的应用程序可以相应地处理该响应。就像我们的厨房示例一样,如果我们正在测试我们的应用程序处理失败的 API 调用的能力,我们实际上必须等待我们的 API 失败以验证它可以处理这种情况——希望这种情况不会经常发生!

添加用户

为了让这个测试通过,我们需要在users.service.ts.

首先,我们首先将我们的导入和员工集合添加到服务中。

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; // Add imports

@Injectable({
  providedIn: 'root'
})
export class UsersService {
  users: Array<object> = [  // Add employee object
    {
      id: '1',
      name: 'Jane',
      role: 'Designer',
      pokemon: 'Blastoise'
    },
    {
      id: '2',
      name: 'Bob',
      role: 'Developer',
      pokemon: 'Charizard'
    },
    {
      id: '3',
      name: 'Jim',
      role: 'Developer',
      pokemon: 'Venusaur'
    },
    {
      id: '4',
      name: 'Adam',
      role: 'Designer',
      pokemon: 'Yoshi'
    }
  ];

  constructor() { }
}

然后,就在我们的构造函数下面,我们可以实现all.

all(): Observable<Array<object>> {
  return of(this.users);
}

ng test再次运行,您现在应该已经通过了测试,包括我们服务方法的新测试。

将用户添加到主页

现在我们的服务方法已经可以使用了,我们可以用这些用户来填充我们的主页。

首先,我们将更新index.html布尔玛,以帮助我们的一些造型。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>AngularTesting</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <!--Add these-->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.1/css/bulma.min.css">
  <script defer src="https://use.fontawesome.com/releases/v5.1.0/js/all.js"></script>
</head>
<body>
  <app-root></app-root>
</body>
</html>

然后home/home.component.ts我们可以在其中添加对我们新服务的调用。

import { Component, OnInit } from '@angular/core';
import { UsersService } from '../services/users/users.service';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
  users;

  constructor(private usersService: UsersService) { }

  ngOnInit() {
    this.usersService.all().subscribe(res => {
      this.users = res;
    });
  }

}

首先,我们导入我们的服务并将其注入到我们组件的构造函数中。然后我们添加对服务方法的调用ngOnInit,并将返回值设置为我们组件的users属性。

要在视图中显示这些用户,请更新 中的模板home/home.component.html

<section class="section is-small">
  <div class="container">
    <div class="columns">
      <div class="column" *ngFor="let user of users">
        <div class="box">
          <div class="content">
            <p class="has-text-centered is-size-5">{% raw %}{{user.name}}{% endraw %}</p>
            <ul>
              <li><strong>Role:</strong> {% raw %}{{user.role}}{% endraw %}</li>
              <li><strong>Pokemon:</strong> {% raw %}{{user.pokemon}}{% endraw %}</li>
            </ul>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>

现在,当您运行ng serve并查看主页时,您应该会看到 Bulma 框中显示的用户。

查找单个用户

现在我们的用户已被填充到我们的主页中,我们将添加另一种服务方法来查找将用于用户配置文件页面的单个用户。

首先,我们将为我们的新服务方法添加测试。

describe('all', () => {
  ...
});

describe('findOne', () => {
  it('should return a single user', () => {
    const userResponse = {
      id: '2',
      name: 'Bob',
      role: 'Developer',
      pokemon: 'Charizard'
    };
    let response;
    spyOn(usersService, 'findOne').and.returnValue(of(userResponse));

    usersService.findOne('2').subscribe(res => {
      response = res;
    });

    expect(response).toEqual(userResponse);
  });
});

在这里,我们为findOne返回单个用户的期望添加了一个测试我们声明一个userResponse变量设置为我们的服务方法的模拟响应,来自用户集合的单个对象。

然后我们创建一个间谍usersService.findOne并返回我们的模拟userResponse变量。使用我们的间谍集,我们调用我们的服务方法并将其返回值设置为response

最后,我们添加我们的断言,该断言response将被设置为服务调用的返回值,userResponse

为了让这个测试通过,我们可以将以下实现添加到users.service.ts.

all(): Observable<Array<object>> {
  return of(this.users);
}

findOne(id: string): Observable<object> {
  const user = this.users.find((u: any) => {
    return u.id === id;
  });
  return of(user);
}

现在,当您运行时,ng test您应该会看到所有测试都处于通过状态。

结论

在这一点上,我希望测试,无论是它的好处还是编写它们的原因,都开始变得更加清晰。就我个人而言,我推迟测试的时间最长,我的原因主要是因为我不明白它们背后的原因,并且测试资源有限。

我们在本教程中创建的并不是视觉上最令人印象深刻的应用程序,但它是朝着正确方向迈出的一步。

在下一个教程中,我们将创建用户个人资料页面和一个使用Pokeapi检索 Pokemon 图像的服务我们将学习如何测试发出 HTTP 请求的服务方法以及如何测试组件。

额外的

如果您希望测试在终端中以更易读的格式显示,可以使用 npm 包。

首先,安装软件包。

npm install karma-spec-reporter --save-dev

这个问题一旦解决完,开放src/karma.conf.js,添加新的包plugins,并更新progress范围内的值reportersspec

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular-devkit/build-angular/plugins/karma'),
      require('karma-spec-reporter') // Add this
    ],
    client: {
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      dir: require('path').join(__dirname, '../coverage'),
      reports: ['html', 'lcovonly'],
      fixWebpackSourcePaths: true
    },
    reporters: ['spec', 'kjhtml'], // Update progress to spec
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false
  });
};

现在,当您运行时ng test,您的测试套件应该具有更具视觉吸引力的输出。

觉得文章有用?

点个广告表达一下你的爱意吧 !😁