[译] RxJS 进阶技巧: 使用 RxJS Marbles 测试竞争条件

原文链接: https://blog.nrwl.io/rxjs-advanced-techniques-testing-race-conditions-using-rxjs-marbles-53e7e789fba5
本文为 RxJS 中文社区 翻译文章,如需转载,请注明出处,谢谢合作!
如果你也想和我们一起,翻译更多优质的 RxJS 文章以奉献给大家,请点击【这里】

angular-rxjs-logo.png

Victor Savkin 是 nrwl.io 的联合创始人。他之前在 Google 的 Angular 核心团队,并建立了依赖注入、变化检测、表单模块和路由模块。

构建 Web 应用涉及到多个后端、Web Workers 和 UI 组件,所有的这一切都会并发地更新应用的状态。由于竞争状态很容易引入 bug 。在本文中我会展示带有这样 bug 的示例,并教你如果在单元测试中使用 RxJS marbles 来暴露问题,以及最后如何来修复问题。

我会使用 Angular 和 RxJS,但我将谈论的任何内容真的是适用于任何 Web 应用的,与使用的框架无关。

问题

我们来看下 MovieShowingsComponent 。它是一个展示电影放映的简单组件。当用户选择一部电影时,组件会立即显示已选择的电影名称,然后,一旦从后端接收到响应,便会显示相应的 showings

@Component({
  selector: 'movie-showings-component',
  templateUrl: './movie-showings.component.html'
})
export class MovieShowingsComponent {
  public movieTitle: string;
  public showings: string[];

  constructor(private backend: Backend) {}

  selectMovie(movieTitle: string) {
    this.movieTitle = movieTitle;

    this.backend.getShowings(movieTitle).subscribe(showings => {
      this.showings = showings;
    });
  }
}

这个组件存在竞争条件。为了看出问题所在,让我们想象如下场景。假设用户先选择了 ‘After the Storm’,然后再选择 ‘Paterson’ 。它看上去应该是这样的:

normal.png

我们在这里做一个假设: ‘After the Storm’ 的响应会先返回。但如果不是这样呢?如果 ‘Paterson’ 的响应先返回会发生什么?

trouble.png

应用其实选的是 ‘Paterson’,但显示的是 showings 却是 ‘After the Storm’ 的,应用被破坏了。

在开始修复它之前,让我们来编写单元测试来暴露这种竞争条件。当然,有很多方法可以做到这点。但由于我们使用 RxJS,我们会用 Marbles ,它是一种测试并发代码的工具,它功能强大并且潜力十足。

Marbles

要使用 Marble,我们需要安装 jasmine-marbles 库。

npm install — save-dev jasmine-marbles

在为组件编写测试之前,我们先通过测试 concat 操作符来看下 Marble 测试通常是如何工作的。

import {cold, getTestScheduler} from 'jasmine-marbles';
import 'rxjs/add/operator/concat';

describe('Test', () => {
  it('concat', () => {
    const one$ = cold('x-x|');
    const two$ = cold('-y|');

    expect(one$.concat(two$)).toBeObservable(cold('x-x-y|'));
  });
});

这里我们使用由 jasmine-marbles 提供的 cold 辅助方法创建了两个 observables: one$two$ 。(如果你对热的和冷的 observables 还不熟悉的话,请阅读 Ben Lesh 的这篇文章)。

接下来,我们使用 concat 操作符来获取结果 observable,它会用来与期待的结果进行比较。

Marbles 是一种用来定义 RxJS observables 的领域特定语言。使用它我们可以定义 observables 何时发出值,何时它们是空闲的,何时它们报错,何时它们被订阅,以及何时它们完成。在我们的测试中,我们定义了两个 observables,其中一个 (’x-x|’) 发出一个 ‘x’,然后等待10毫秒再发出另一个 ‘x’ ,然后完成。而另一个在发出 ‘y’ 前会等待10毫秒。

通常发出单个字母的字符串是不够的。cold 辅助函数提供了一种方式以将其映射成其他的对象,像这样:

import {cold, getTestScheduler} from 'jasmine-marbles';
import 'rxjs/add/operator/concat';

describe('Test', () => {
  it('concat', () => {
    const one$ = cold('x-x|', {x: 'some value'});
    const two$ = cold('-y|', {y: 999});

    expect(one$.concat(two$)).toBeObservable(cold('a-a-b|', {a: 'some value', b: 999}));
  });
});

与许多 DSL 一样,我们使用 Marbles 来提升我们测试代码的可读性。Marbles 在此方面做的非常棒,我们只要稍稍看一眼测试代码便能明白测试代码在做什么。

如果你想了解有关 Marbles 测试的更多内容,请观看这个视频

测试竞争条件

有了这个强力工具,我们来编写单元测试以暴露出竞争条件。

import { MovieShowingsComponent } from './movie-showings.component';
import { cold, getTestScheduler } from 'jasmine-marbles';

describe('MovieShowingsComponent', () => {
  it('should not have a race condition', () => {
    const backend = jasmine.createSpyObj('backend', ['getShowings']);
    const cmp = new MovieShowingsComponent(backend);

    backend.getShowings.and.returnValue(cold('--x|', {x: ['10am']}));
    cmp.selectMovie('After the Storm');

    backend.getShowings.and.returnValue(cold('-y|', {y: ['11am']}));
    cmp.selectMovie('Paterson');

    // 这会清除所有的 observables
    getTestScheduler().flush();

    expect(cmp.movieTitle).toEqual('Paterson');
    expect(cmp.showings).toEqual(['11am']); // 这会失败,因为 showings 是 ['10am'] 。
  });
});

修复竞争条件

我们再来看下我们的组件。

@Component({
  selector: 'movie-showings-component',
  templateUrl: './movie-showings.component.html'
})
export class MovieShowingsComponent {
  public movieTitle: string;
  public showings: string[];

  constructor(private backend: Backend) {}

  selectMovie(movieTitle: string) {
    this.movieTitle = movieTitle;

    this.backend.getShowings(movieTitle).subscribe(showings => {
      this.showings = showings;
    });
  }
}

每次用户选择一个电影,我们都创建一个新的孤立的 observable 。如果用户点击两次,我们就有两个 observables,它们之间无法协调。这才是问题的根源。

让我们通过引入一个所有 getShowings 调用的 observable 来改变现状。

@Component({
  selector: 'movie-showings-cmp',
  templateUrl: './movie-showings.component.html'
})
export class MovieShowingsComponent {
  public movieTitle: string;
  public showings: string[];

  private getShowings = new Subject<string>();

  constructor(private backend: Backend) {
  }

  showShowings(movieTitle: string) {
    this.movieTitle = movieTitle;
    this.getShowings.next(movieTitle);
  }
}

接下来,我们将 observable 映射成 showings 列表。

@Component({
  selector: 'movie-showings-cmp',
  templateUrl: './movie-showings.component.html'
})
export class MovieShowingsComponent {
  public movieTitle: string;
  public showings: string[];

  private getShowings = new Subject<string>();

  constructor(private backend: Backend) {
    this.getShowings.switchMap(movieTitle => this.backend.getShowings(movieTitle)).subscribe(showings => {
      this.showings = showings;
    });
  }

  showShowings(movieTitle: string) {
    this.movieTitle = movieTitle;
    this.getShowings.next(movieTitle);
  }
}

通过这样,我们用单个的高阶 observable 来替代一组孤立的 observables 的集合,我们可以对它应用同步性的操作符。同步性的操作符指的就是 switchMap

switchMap 操作符只会订阅 backend.getShowings 的最新调用。如果执行了另一个调用,它会取消对前一个调用的订阅。

solved.png

随着这次变化,我们的测试将会通过。

源码

你可以在这个仓库找到源码。注意 tsconfig.spec.json 文件中的 "skipLibCheck": true 。

总结

在本文中,我们看过了一个由竞争条件引起的 bug 示例。我们使用了 Marbles, 这是一种测试异步代码的强大方法,以暴露单元测试中的 bug 。然后我们通过使用单个的高阶 observable (应用了 switchMap 操作符) 来重构代码以修复这个 bug 。

Victor Savkin 是 Nrwl — 企业级 Angular 咨询公司 的联合创始人

nrwl-logo.png

如果你喜欢的话,请点击下面的💚,这样其他人也会在 Medium 看到此篇文章。关注 @victorsavkin 以阅读更多关于 Angular 的内容。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容

  • 介绍 RxJS是一个异步编程的库,同时它通过observable序列来实现基于事件的编程。它提供了一个核心的类型:...
    泓荥阅读 16,581评论 0 12
  • 本篇文章介主要绍RxJava中操作符是以函数作为基本单位,与响应式编程作为结合使用的,对什么是操作、操作符都有哪些...
    嘎啦果安卓兽阅读 2,835评论 0 10
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,449评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,596评论 18 139
  • 竹直南山口, 溪流北村头。 橘红秋柿树, 云影雁声留。
    清山清水阅读 306评论 1 3