发布于 2026-01-06 0 阅读
0

献给所有厌恶单元测试的UI开发者的指南。DEV全球项目展示挑战赛,由Mux呈现:展示你的项目!

献给所有厌恶单元测试的UI开发人员的指南。

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

编写测试就像在上菜前先尝尝味道。单元测试的重要性在编程的各个层面都广为人知,但在 UI 开发人员中却常常被忽视。本文将简要介绍如何通过在代码中融入单元测试的关键概念,开启您成为更优秀的前端工程师的旅程。

概述

  1. 单元测试的重要性

  2. 示例应用程序

  3. 结论

单元测试的重要性

编写单元测试似乎是一种额外的工作,因为直接使用功能就能进行测试。如果您遇到这种两难境地,可以记住以下几点:

  1. 单元测试不仅可以提高质量,还可以减少调试时间:单元测试可以帮助您了解应用程序的哪些部分按预期工作,哪些部分没有按预期工作,因此可以比使用 console.log 或调试器更快地缩小错误原因的范围。

  2. 我们都是JS开发者!:我们所有开发者都曾有过这样的经历:要么构建测试用的UI组件和粗略的HTML代码来测试底层逻辑/服务,要么等到展示组件完成后才进行测试。编写单元测试可以让你迭代地构建功能组件,而无需测试不必要的UI元素。

  3. 协作自由:在团队中工作时,我经常看到成员们各自独立地处理功能,而庞大的代码库使得重构和修复 bug 时总是担心会破坏一些正在运行的代码。如果编写合适的单元测试,并在代码编写过程中及时发现任何可能出现的破坏,就能避免这种情况的发生,从而为后续的开发人员提供参考。

  4. 单元测试无需底层文档,它直接声明给定代码单元的用途。这降低了开发人员显式编写代码文档的需求(也建议所有 JavaScript 开发人员采用声明式编程风格),产品团队可以将更多精力集中在应用程序的外观和用户体验上,而不是功能本身。
    使用 Jest 等测试框架还可以在 CI/CD 环境中测试前端代码,这与第三点相符,因为它有助于生成关于代码健康状况和测试覆盖率的定期报告。

以下是在编写单元测试时应牢记的一些关键准则:
  1. 了解应该编写哪些类型的单元测试取决于应用程序组件的类型(例如,展示型组件、逻辑容器、服务等)。理解应该测试哪些内容,有助于合理化你在每个层级编写单元测试所花费的额外精力。

  2. 编写函数式 JavaScript,并尽可能将应用程序拆分为展示型组件和逻辑组件。这有助于提高单元测试的针对性,并减少编写单元测试所需的时间。

  3. 编写代码的同时也要编写测试。这绝对是最重要的!我再怎么强调重写旧代码并为已开发的组件添加单元测试有多么痛苦都不为过。这需要花费大量的时间和精力来弄清楚你写了什么以及需要测试什么。编写测试时,我们的目标应该是编写能够通过测试的代码,而不是反过来。

  4. 在开始编写应用程序之前,先练习编写测试。大多数开发者之所以避免编写测试,是因为他们要么不了解,要么对一些基础知识不够熟悉,例如模拟类、测试异步调用、模拟HTTP调用等等。通过练习可以消除这些困惑和误解。因此,练习单元测试的时间应该和练习编写应用程序代码的时间一样多。

了解了编写测试的重要性之后,我们将通过一个 Angular 应用程序示例,并使用 Jest 为其编写一些单元测试。

为什么是 Jest?

Jest 是一个优秀的测试框架,它为多个 JavaScript 框架提供统一的、非基于浏览器的单元测试选项。

点击这里了解更多信息

还要特别推荐一下jest-angular-preset库,它让 Jest 与 Angular 的集成变得非常简单。Jest 提供了三个 Angular 默认测试配置所不具备的强大功能:快照测试、无需浏览器即可运行的单元测试以及自动模拟。我建议大家都能了解这些功能,以便充分发挥这个优秀框架的潜力。

设置 :

如果您之前从未使用过 Angular,请按照此处的官方 Angular 设置指南进行操作。

我们的应用程序将包含三个主要组件:AppComponent、ListingService 和 ListRowComponent。但在编写组件和测试用例之前,我们需要先配置 Jest。

Jest 设置步骤:

使用此快速指南进行初始设置、删除基于 Karma 的代码并运行 Jest。

Jest 允许你将配置存储在配置文件中的 Jest 字段package.json或单独的文件中。jest.config.js

我建议大家通读一遍官方配置指南,了解项目可以有哪些配置以及可能需要哪些配置。为了帮助大家,我建议至少重点关注以下几个方面:setupFilesAfterEnv, coverageDirectory, coverageReporters, transformIgnorePatterns, modulePathIgnorePatterns, moduleNameMapper, testPathIgnorePatterns

这是我们示例应用程序中的 jest.config.js 文件。


module.exports = {
    "preset": "jest-preset-angular",
    "setupFilesAfterEnv": ["<rootDir>/setupJest.ts"],
    globals: {
      "ts-jest": {
        tsConfig: '<rootDir>/tsconfig.spec.json',
        "diagnostics":false,
        "allowSyntheticDefaultImports": true,
        "stringifyContentPathRegex": "\\.html$",
        astTransformers: [require.resolve('jest-preset-angular/InlineHtmlStripStylesTransformer')],
      }
    },
    coverageDirectory:'<rootDir>/output/coverage/jest',
    transformIgnorePatterns: ["node_modules/"],
    "coverageReporters": [
      "text",
      "json",
    ],
    "reporters": [
      "default",
    ],
    snapshotSerializers: [
      'jest-preset-angular/AngularSnapshotSerializer.js',
      "jest-preset-angular/AngularSnapshotSerializer.js",
      "jest-preset-angular/HTMLCommentSerializer.js"
    ],
    "transform": {
      '^.+\\.(ts|html)$': 'ts-jest',
      "^.+\\.js$": "babel-jest",
    },
    modulePathIgnorePatterns: [],
    moduleNameMapper: {},
    testPathIgnorePatterns:['sampleCodes/'],
  };


这是我的 tsconfig.spec.ts 文件。


{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jest", "node"],
    "emitDecoratorMetadata": true,
    "allowJs": true
  },
  "files": [
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

注意:不要直接复制粘贴代码,理解配置确实有助于您自行设置项目的整个配置。

我还建议全局安装 Jest。

npm install -g jest

这在运行快照测试所需的 jest cli 命令(例如使用更新快照jest -u)时非常有用。

最后运行 Jest 并检查自动创建的基本测试是否ng generate正在运行。

jest --coverage

这里有一份很棒的指南,介绍了如何测试组件、改进测试用例,以及 DOM 测试库如何帮助我们完成这项工作。

为展示组件编写单元测试

如果你已经熟练掌握了纯展示型组件的编写,那你真是太棒了!如果你还没有掌握,我建议你开始练习如何将你的应用程序代码拆分成逻辑容器和展示型组件。

Jest 能够使用快照测试来测试 UI 组件。点击此处了解更多关于快照测试的信息。

这样可以节省编写 DOM 查询的时间。根据文档,应该将这些快照与代码一起提交,以便验证 UI 组件在 DOM 中的渲染方式。

何时不应使用快照?

如果组件足够基础和简单,快照测试应该可以覆盖大部分 UI 测试,但要避免将其用于像列表这样的展示性组件,因为你需要检查渲染的行总数,或者用于需要验证业务逻辑表示的组件。

下面找到示例 ListRowComponent


@Component({
  selector: 'app-list-row-component',
  templateUrl: './list-row-component.component.html',
  styleUrls: ['./list-row-component.component.scss'],

})
export class ListRowComponentComponent implements OnInit {

  @Input() firstName:string;
  @Input() lastName:string;
  @Input() gender:string;
  @Output() rowClick = new EventEmitter();

  getClass(){
    return {
      'blue':this.gender==='male',
      'green':this.gender==='female'
    }
  }
  constructor() { 
  }
  ngOnInit() {
  }
}

下面找到示例 ListRowComponent.spec 文件



describe('ListRowComponentComponent', () => {
  let component: ListRowComponentComponent;
  let fixture: ComponentFixture<ListRowComponentComponent>;


  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ListRowComponentComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ListRowComponentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should render the component with blue color class',()=>{
    component.firstName = 'James'
    component.lastName = 'Bond'
    component.gender = 'male'
    fixture.detectChanges()

    expect(fixture).toMatchSnapshot();
  })
  it('should render the component with green color class',()=>{
    component.firstName = 'James'
    component.lastName = 'Bond'
    component.gender = 'female'
    fixture.detectChanges()

    expect(fixture).toMatchSnapshot();
  })

  it('should emit events onClick',done=>{
    let buttonClicked = false
    component.rowClick.subscribe(()=>{
      buttonClicked =true;
      expect(buttonClicked).toBeTruthy()
      done();
    })
    var btn = getByTestId(fixture.nativeElement,'row-click');
    simulateClick(btn);
  })
});


注意:如果您注意到的话,我data-testid在上面的单元测试中使用了查询按钮的方法。我建议所有开发人员都采用这种方法,它能使我们的测试更具适应性和鲁棒性。

为服务编写单元测试

首先,在我开始为服务或容器编写单元测试之前,以下一些概念让我感到困惑:

模拟依赖项。只需简单地在谷歌上搜索一下,就能找到很多很棒的教程,但大多数教程都使用组件构造函数或推荐使用 Jest 的自动模拟功能来模拟依赖项。具体使用哪种方法取决于你的个人偏好。对我来说,在使用 Angular 的依赖注入实例化组件时模拟依赖项至关重要,而且我找到了一种非常好的方法来实现这一点。

你可以阅读这篇关于此主题的精彩文章。

模拟 Store:建议在服务中为 ngrx store(https://ngrx.io/)编写 getter 和 selector,以便组件可以与 store 一起复用。这意味着在服务中模拟 Store 变得非常重要。

describe('Service:StoreService', () => {
  let backend: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientModule, HttpClientTestingModule, RouterTestingModule],
      providers: [
        provideMockStore({ initialState }),
      ],
      schemas:[NO_ERRORS_SCHEMA]
    });
    backend = TestBed.get(HttpTestingController);
  });

  afterEach(inject(
    [HttpTestingController],
    (_backend: HttpTestingController) => {
      _backend.verify();
    }
  ));

了解更多

使用 Marble 测试:最后,您在 Angular 项目中创建的大多数服务都会使用 RxJS。为了正确测试您的服务和逻辑容器组件,了解如何测试这些 Observable(最好使用jasmine-marbles)至关重要。

以下是迈克尔·霍夫曼撰写的一篇很棒的文章,可以帮助你更好地理解这一点。

样品服务


@Injectable({
  providedIn: 'root'
})
export class ListingService {

  constructor(
    public http: HttpClient
  ) { }

  public getHeaderWithoutToken() {
    return new HttpHeaders()
      .append('Content-Type', 'application/json')
      .append('Accept', 'application/json');
  }

  public getHeader(tokenPrefix = '') {
    let headers = this.getHeaderWithoutToken();
    return { headers };
  }

  public doGet(url,header=this.getHeader()){
    return this.http.get(url,header);
  }
  public getList() : Observable<UserModel[]>{
    return this.doGet('http://example.com/users')
    .pipe(
      map((res:any[])=>{
        return res.map(toUserModel)
    }))
  }
}

使用 Jest 测试服务


describe('ListingServiceService', () => {
  let service: ListingService;
  let backend: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientModule, HttpClientTestingModule],
      providers: [
        ListingService
      ],
      schemas:[NO_ERRORS_SCHEMA,CUSTOM_ELEMENTS_SCHEMA]
    });
    backend = TestBed.get(HttpTestingController);
    service = TestBed.get(ListingService);
  });

  afterEach(inject(
    [HttpTestingController],
    (_backend: HttpTestingController) => {
      _backend.verify();
    }
  ));

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

  const url = 'http://example.com/users';
  test('should fetch a list of users',done=>{
    service.getList()
    .subscribe(data=>{
      expect(data).toEqual(outputArray)
      done()
    })
    backend.expectOne((req: HttpRequest<any>) => {
        return req.url === url && req.method === 'GET';
      }, `GET all list data from ${url}`)
      .flush(outputArray);
  })
});

为容器组件编写单元测试

容器组件是复杂的组件,这种复杂性常常会导致人们在编写容器组件的单元测试时感到困惑。为了避免这种情况,您可以采用浅层测试和深层测试相结合的方式来编写单元测试。

您可以在这里了解更多关于这种方法的信息。

示例应用程序容器组件


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

export class AppComponent implements OnInit{
  title = 'my-test-app';
  list$ : Observable<UserModel[]>;
  constructor(
    private listService :ListingService,
  ){
  }
  ngOnInit(){
    this.initListService()
  }
  initListService(){
    this.list$ =  this.listService.getList();
  }
  onClicked(user){

  }
}

为单元测试设置容器

let fixture : ComponentFixture<AppComponent>;
  let service : ListingService;
  let component : AppComponent;
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
      providers:[
        {provide:ListingService,useClass:MockListService}
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA]
    }).compileComponents();
  }));
  beforeEach(()=>{
    fixture = TestBed.createComponent(AppComponent)
    component = fixture.debugElement.componentInstance;
    service = fixture.componentRef.injector.get(ListingService);
    fixture.detectChanges()
  })

编写浅层测试

单元测试仅测试与当前容器中其他组件隔离的部分,例如作为此组件模板一部分编写的所有 DOM 组件是否按预期渲染,组件是否通过从服务获取数据进行设置,以及组件输出是否按预期工作。


  it('should create the app', () => {

    expect(component).toBeTruthy();
  });


  it('should render title in a h1 tag',() => {
    const compiled = fixture.debugElement.nativeElement;
    expect(queryByTestId(compiled,'app-title')).not.toBeNull();
    expect(queryByTestId(compiled,'app-title').textContent).toEqual(component.title)
  });

  test('should fetch the user list from the listing service',async(()=>{
    const spy = jest.spyOn(service,'getList');
    var expectedObservable = cold('-a',{a:outputArray})
    spy.mockReturnValue(expectedObservable)
    component.ngOnInit()
    fixture.detectChanges()
    expect(spy).toHaveBeenCalled();
    expect(component.list$).toBeObservable(expectedObservable)
    getTestScheduler().flush()
    fixture.detectChanges()
    component.list$.subscribe((o)=>{
      fixture.detectChanges()
      var list = fixture.nativeElement.querySelectorAll('app-list-row-component')
      expect(list.length).toEqual(outputArray.length)
    })
    spy.mockRestore()
  }))

编写深度测试

一组单元测试,目的是检查组件中子组件/内部组件与附加到该组件的提供者和调度器之间的交互。


test('should call onClicked when app-list-row-component is clicked',()=>{
    const spy = jest.spyOn(service,'getList');
    var expectedObservable = cold('a',{a:outputArray})
    spy.mockReturnValue(expectedObservable)
    component.initListService()
    getTestScheduler().flush()
    var onClicked = spyOn(component,'onClicked').and.callThrough();
    component.list$.subscribe((o)=>{
      fixture.detectChanges()
      var row0 = fixture.debugElement.query((el)=>{
        return el.properties['data-testid'] === 'row0'
      }).componentInstance as ListRowComponentComponent
      row0.rowClick.emit();
      expect(onClicked).toHaveBeenCalledWith(outputArray[0])
    })
  })

结论

我希望通过这篇文章,能让读者简要了解将单元测试集成到前端代码中所需的关键概念,并提供一些关于如何为复杂组件编写单元测试以及如何设计应用程序以便轻松维护健康代码库的技巧。

您可以在这里找到本文中使用的示例应用程序的完整代码。

欢迎随意 fork 并使用这套配置进行单元测试。

文章来源:https://dev.to/divye1995/a-guide-for-every-ui-developer-with-an-aversion-to-unit-tests-40d