使用 Karma 和 Jasmine 进行角度单元测试

Jasmine 是一个 JavaScript 测试框架,而 Karma 是一个基于节点的跨多个真实浏览器的 JavaScript 代码测试工具。

Angular 单元测试检查 Angular 应用程序中孤立的代码片段。它允许用户在不中断其应用程序的任何其他部分的情况下添加新功能。Jasmine 是一个 JavaScript 测试框架,而Karma 是一个基于节点的跨多个真实浏览器的 JavaScript 代码测试工具。此博客可帮助您开始使用 Karma 和 Jasmine 进行 Angular 单元测试。

Angular 单元测试简介

首先,你的机器上必须安装 Angular。这就是您需要开始安装 Angular 的地方。如果您已经安装了 Angular,请跳过下一步。

创建和管理 Angular 项目非常简单。有各种相互竞争的库、框架和工具可以解决这些问题。Angular 团队创建了 Angular CLI,这是一个用于简化 Angular 项目的命令行工具。Angular CLI 是通过 npm 安装的,所以你需要在你的机器上安装 Node。

安装 Node 后,您可以在终端中运行以下命令。

完成安装所需的时间可能会发生变化。完成后,您可以通过在终端中键入以下命令来查看 Angular CLI 的版本。

现在您已经安装了 Angular CLI,您已准备好创建 Angular 示例应用程序。在您的终端中运行以下命令。

ng 新的角度单元测试应用程序

执行命令后,会询问你是否要添加 Angular 路由。键入 Y 并按 ENTER。然后,您将被要求在您的应用程序的样式表格式的多个选项之间进行选择。这将需要几分钟,一旦完成;您将转到您的测试应用程序。

单元测试测试孤立的代码单元。单元测试旨在回答以下问题,

  • 排序函数是否以正确的顺序对列表进行了排序?
  • 我是否能够正确思考逻辑?

要回答这些问题,隔离被测代码单元至关重要。这是因为当您测试排序功能时,您不想被迫创建相关部分,例如进行任何 API 调用以获取要排序的实际数据库数据。

您已经知道单元测试测试软件或应用程序的各个组件。这背后的主要动机是检查所有单独的部分是否按预期工作。单元是可以测试的最小的软件组件。通常,它有多个输入和一个输出。

让我们进入 Angular Web 应用程序测试部分。

在您的终端中运行以下命令。

等待几秒钟后,您将看到 Web 浏览器的一个新窗口在如下所示的页面上打开,如下所示。

解读 Karma 和 Jasmine 在 Angular 单元测试中的作用

什么是 Karma 测试运行器?

Karma 是 Angular JS 团队开发的测试自动化工具,因为使用当前工具测试他们自己的框架功能变得越来越困难。因此,他们开发了 Karma 并将其转换为 Angular 作为使用 Angular CLI 开发的应用程序的默认测试运行器。除了与 Angular 相处之外,它还提供了根据您的工作流程定制 Karma 的灵活性。它可以选择在不同的浏览器和设备(如平板电脑、手机等)上测试你的代码。Karma 为你提供了用其他测试框架(如Mocha和QUnit)替代 Jasmine 的选项。

这是示例项目中 karma.conf.js 文件的内容。

什么是茉莉花?Jasmine 是一个免费的开源行为驱动开发 (BDD) 框架,可以测试 JavaScript 代码,并且与 Karma 配合得很好。与 Karma 一样,它也是 Angular 文档中推荐的测试框架。

测试运行的流程,

测试组件add-organization.component.ts

import { TranslateService } from '@ngx-translate/core';
import { SharedService } from 'src/app/shared/services/shared.service';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { appConstants, allowedFileFormat, AppRoutes } from 'src/app/app.constants';
import { SelectOption } from 'src/app/shared/interface/select-option';
import { OrganizationService } from '../organization.service';
import { getOrganizations } from 'src/app/shared/config/api';
import { Router, ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from 'src/app/shared/components/confirm-dialog/confirm-dialog.component';

@Component({
selector: 'app-add-organization',
templateUrl: './add-organization.component.html',
styleUrls: ['./add-organization.component.scss']
})
export class AddOrganizationComponent implements OnInit {

orgId: any;
submitted = false;
logoFileFormat = allowedFileFormat.logoFileFormat;
logoFileSize = allowedFileFormat.logoFileSize;
selectedImageLogo!: File;
selectedImageLogoUrl = '';
countryOptions: SelectOption[] = [];
menuList = [{
label: 'HOME',
url: ''
}, {
label: 'ORGANIZATION',
url: '/app/organization'
}, {
label: 'CREATE ORGANIZATION',
url: ''
}];
themeData: any;
configurationTabStatus = true;
loading = false;
userData = [{name: 'name'}];
userCount = 15;
undefinedVariable: any;
currentStep = 1;
completedSteps: number[] = [];
orgForm = this.createForm();

constructor(private fb: FormBuilder, public sharedService: SharedService,
public translateService: TranslateService, private route: Router,
public organizationService: OrganizationService, private activatedRoute: ActivatedRoute,
private confirmDialog: MatDialog) { }

ngOnInit(): void {
this.orgId = this.activatedRoute.snapshot.params['orgId'];
if (this.orgId) {
this.configurationTabStatus = false;
}
this.getCountries();
this.getOrganizationDetails();
}

createForm() {
return this.fb.group({
firstName: ['', [Validators.required, Validators.maxLength(200)]],
lastName: ['', [Validators.required, Validators.maxLength(200)]],
isActive: [true],
email: ['', [Validators.required, Validators.email, Validators.maxLength(200)]],
});
}

get formControl() {
return this.orgForm.controls;
}

isFieldInvalid(field: string) {
return (
(this.formControl[field].invalid && this.formControl[field].touched) ||
(this.formControl[field].untouched && this.submitted && this.formControl[field].invalid)
);
}

displayFieldCss(field: string) {
return {
[appConstants.default.hasErrorClass]: this.isFieldInvalid(field),
};
}

onViewUser() {
if (this.orgId) {
this.sharedService.setOrganizationId(this.orgId);
this.route.navigate([AppRoutes.userPath]);
this.userData = [];
this.submitted = false;
this.userCount = 10;
this.undefinedVariable = undefined;
} else {
this.route.navigate([AppRoutes.addUser]);
this.userData = [{name: 'ZYMR'}];
this.submitted = true;
this.userCount = 20;
this.undefinedVariable = 'Test';
}
}

isCompleted = (step: any) => this.completedSteps.indexOf(step) !== -1;

navigateToStep(step: any) {
if(this.currentStep !== step && (this.orgId || this.isCompleted(step))) {
switch (step) {
case 1:
this.route.navigate([AppRoutes.user + this.orgId]);
break;
case 2:
this.route.navigate([AppRoutes.organization + this.orgId]);
break;
case 3:
this.route.navigate([AppRoutes.userPath + this.orgId]);
break;
case 4:
this.route.navigate([AppRoutes.addUser + this.orgId]);
break;
default:
break;
}
}
}

changeOrgStatus(event: any) {
if (this.orgId && !event.checked) {
const confirmDialog = this.confirmDialog.open(ConfirmDialogComponent, {
disableClose: true,
data: {
title: this.translateService.instant('COMMON.ACTION_CONFIRM.TITLE'),
message: this.translateService.instant('ORGANIZATIONS.ORGANIZATIONS_DEACTIVE'),
},
maxWidth: '100vw',
width: '600px',
});
if (confirmDialog) {
confirmDialog.afterClosed().subscribe(result => {
if (result === true) {
this.formControl['isActive'].setValue(event.checked);
} else {
this.formControl['isActive'].setValue(!event.checked);
}
});
}
}
}

onSubmit(): void {
const formData = new FormData();
formData.append('firstName', this.formControl['firstName'].value);
formData.append('lastName', this.formControl['lastName'].value);
formData.append('isActive', this.formControl['isActive'].value);
formData.append('email', this.formControl['email'].value);

if (this.orgId) {
formData.append('Id', this.orgId);
this.createEditNewOrganization(formData, appConstants.methodType.put);
} else {
this.createEditNewOrganization(formData, appConstants.methodType.post);
}
}

private createEditNewOrganization(formData: FormData, methodType: string): void {
this.submitted = true;
if (this.orgForm.invalid) {
return;
}
this.sharedService.showLoader();
this.organizationService.postFile(getOrganizations, formData, methodType).subscribe({
next: (res: any) => {
this.sharedService.responseHandler(res, true, true, true);
if (this.sharedService.isApiSuccess(res)) {
this.orgId = res.data;
if (methodType === appConstants.methodType.post) {
this.route.navigate([AppRoutes.editOrganization + '/' + this.orgId]);
} else {
this.getOrganizationDetails();
}
}
},
error: (err: any) => {
this.sharedService.errorHandler(err);
},
complete: () => this.sharedService.hideLoader()
}
);
}

private getOrganizationDetails() {
if (this.orgId) {
this.loading = true;
const apiUrl = getOrganizations + '/' + this.orgId;
this.sharedService.showLoader();
this.organizationService.get(apiUrl).subscribe({
next: (res: any) => {
if (this.sharedService.isApiSuccess(res)) {
this.configurationTabStatus = false;
this.selectedImageLogoUrl =
res.data.imageURL ? (res.data.imageURL + '?modifiedDate=' + res.data.modifiedDate) : res.data.imageURL;
const formattedData = this.organizationService.prepareOrganizationDetailsResponse(res.data);
this.orgForm.patchValue(formattedData);
}
},
error: (err: any) => {
this.sharedService.errorHandler(err);
},
complete: () => {
this.loading = false;
this.sharedService.hideLoader();
}
}
);
}
}

private getCountries(): void {
this.sharedService.getCountriesList().subscribe(
(res: Array) => {
this.countryOptions = res;
}
);
}
}

add-organization.component.spec.ts

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddOrganizationComponent } from './add-organization.component';
import { HttpClientModule } from '@angular/common/http';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule, TranslateLoader, TranslateFakeLoader } from '@ngx-translate/core';
import { ToastrModule } from 'ngx-toastr';
import { SharedModule } from 'src/app/shared/modules/shared.module';
import { OrganizationService } from '../organization.service';
import { appConstants, AppRoutes } from 'src/app/app.constants';
import { defer } from 'rxjs';

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

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientModule,
RouterTestingModule,
SharedModule,
ToastrModule.forRoot(),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateFakeLoader
}
})
],
declarations: [AddOrganizationComponent],
providers: [
OrganizationService
]
}).compileComponents();

fixture = TestBed.createComponent(AddOrganizationComponent);
component = fixture.componentInstance;
});

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

it('should get organization Form', () => {
component.createForm();
expect(component.formControl).not.toBeNull();
});

it('should page is in edit mode', () => {
(component as any).activatedRoute = {
snapshot: {
params: {
orgId: '123'
}
}
};
spyOn((component as any), 'getCountries');
spyOn((component as any), 'getOrganizationDetails');
component.orgId = '123';
component.ngOnInit();

expect(component.configurationTabStatus).toBeFalsy();
});

it('should initialize country dropdown', waitForAsync(() => {
const countryList = [{
value: 1,
display: 'india',
}];
spyOn((component as any).sharedService, 'getCountriesList').and.returnValue(promiseData(countryList));
(component as any).getCountries();
fixture.whenStable().then(() => {
expect(component.countryOptions).toEqual(countryList);
});
}));

it('should be toggled to deactivated organization', waitForAsync(() => {
component.orgId = '123';
component.createForm();
spyOn((component as any).confirmDialog, 'open').and.returnValue({ afterClosed: () => promiseData(true) });
component.changeOrgStatus({ checked: false });
fixture.whenStable().then(() => {
expect(component.formControl['isActive'].value).toBeFalsy();
});
}));

it('should be toggled activated organization', waitForAsync(() => {
component.orgId = '123';
component.createForm();
spyOn((component as any).confirmDialog, 'open').and.returnValue({ afterClosed: () => promiseData(false) });
component.changeOrgStatus({ checked: false });
fixture.whenStable().then(() => {
expect(component.formControl['isActive'].value).toBeTruthy();
});
}));

it('should save organization details', () => {
component.orgId = '';
const spy = spyOn((component as any), 'createEditNewOrganization');
component.onSubmit();
expect(spy).toHaveBeenCalled();
});

it('should update organization details', () => {
component.orgId = '123';
const spy = spyOn((component as any), 'createEditNewOrganization');
component.onSubmit();
expect(spy).toHaveBeenCalled();
});

it('should save organization data on createEditNewOrganization call', waitForAsync(() => {
component.createForm();
component.orgForm.patchValue({
lastName: 'name',
firstName: 'vatNumber',
email: '[email protected]',
});
spyOn((component as any).organizationService, 'postFile').and.returnValue(promiseData({
code: '',
data: '123',
message: '',
status: appConstants.responseStatus.success
}));
const spy = spyOn((component as any).sharedService, 'showLoader');
const spyResponseHandler = spyOn((component as any).sharedService, 'responseHandler');
const navigateByUrlSpy = spyOn((component as any).route, 'navigateByUrl');
(component as any).createEditNewOrganization({}, appConstants.methodType.post);
fixture.whenStable().then(() => {
expect(spy).toHaveBeenCalled();
expect(spyResponseHandler).toHaveBeenCalled();
expect(navigateByUrlSpy).toHaveBeenCalled();
});
}));

it('should update organization data on createEditNewOrganization call', waitForAsync(() => {
component.createForm();
component.orgForm.patchValue({
lastName: 'name',
firstName: 'vatNumber',
email: '[email protected]',
});
spyOn((component as any).organizationService, 'postFile').and.returnValue(promiseData({
code: '',
data: '123',
message: '',
status: appConstants.responseStatus.success
}));
const spy = spyOn((component as any).sharedService, 'showLoader');
const getOrganizationDetails = spyOn((component as any), 'getOrganizationDetails');
const spyResponseHandler = spyOn((component as any).sharedService, 'responseHandler');
(component as any).createEditNewOrganization({}, appConstants.methodType.put);
fixture.whenStable().then(() => {
expect(spy).toHaveBeenCalled();
expect(getOrganizationDetails).toHaveBeenCalled();
expect(spyResponseHandler).toHaveBeenCalled();
});
}));

it('should org form invalid on createEditNewOrganization call', () => {
component.createForm();
component.orgForm.patchValue({
name: 'name',
});
(component as any).createEditNewOrganization({}, appConstants.methodType.post);
expect(component.submitted).toBeTruthy();
});

it('should handle error while saving organization data on createEditNewOrganization call', waitForAsync(() => {
component.createForm();
component.orgForm.patchValue({
lastName: 'name',
firstName: 'vatNumber',
email: '[email protected]',
});
spyOn((component as any).organizationService, 'postFile').and.returnValue(rejectPromise({
code: '',
data: '',
message: '',
status: appConstants.responseStatus.error
}));
const spyResponseHandler = spyOn((component as any).sharedService, 'errorHandler');
(component as any).createEditNewOrganization({}, appConstants.methodType.post);
fixture.whenStable().then(() => {
expect(spyResponseHandler).toHaveBeenCalled();
});
}));

it('should get organization details on getOrganizationDetails call', waitForAsync(() => {
component.createForm();
component.orgId = '123';
const orgDetails = {
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
isActive: true
};
spyOn((component as any).organizationService, 'get').and.returnValue(promiseData({
code: '',
data: {
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
imageURL: 'http://www.test.com/img1',
modifiedDate: '12-12-12',
isActive: true
},
status: appConstants.responseStatus.success,
message: ''
}));
spyOn(component.sharedService, 'isApiSuccess').and.returnValue(true);
spyOn(component.organizationService, 'prepareOrganizationDetailsResponse').and.returnValue(orgDetails);
(component as any).getOrganizationDetails();
fixture.whenStable().then(() => {
expect(component.selectedImageLogoUrl).toEqual('http://www.test.com/img1?modifiedDate=12-12-12');
expect(component.configurationTabStatus).toBeFalsy();
expect(component.orgForm.value).toEqual({
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
isActive: true,
});
});
}));

it('should get organization details but imageUrl is empty on getOrganizationDetails call', waitForAsync(() => {
component.createForm();
component.orgId = '123';
const orgDetails = {
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
isActive: true
};
spyOn((component as any).organizationService, 'get').and.returnValue(promiseData({
code: '',
data: {
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
imageURL: '',
modifiedDate: '',
isActive: true
},
status: appConstants.responseStatus.success,
message: ''
}));
spyOn(component.sharedService, 'isApiSuccess').and.returnValue(true);
spyOn(component.organizationService, 'prepareOrganizationDetailsResponse').and.returnValue(orgDetails);
(component as any).getOrganizationDetails();
fixture.whenStable().then(() => {
expect(component.selectedImageLogoUrl).toEqual('');
expect(component.configurationTabStatus).toBeFalsy();
expect(component.orgForm.value).toEqual({
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
isActive: true,
});
});
}));

it('should handle error while getting organization details on getOrganizationDetails call', waitForAsync(() => {
component.createForm();
component.orgId = '123';
spyOn((component as any).organizationService, 'get').and.returnValue(rejectPromise({
code: '',
data: {},
status: appConstants.responseStatus.error,
message: ''
}));
const spy = spyOn(component.sharedService, 'errorHandler');
(component as any).getOrganizationDetails();
fixture.whenStable().then(() => {
expect(spy).toHaveBeenCalled();
});
}));

it('should return class on displayFieldCss', () => {
component.createForm();
component.orgForm.controls['email'].setValue('invalid_email@@dotcom');
component.submitted = true;
expect(component.displayFieldCss('email')).toEqual({
'has-error': true
});
});

it('should set organization id and navigate to user list page', () => {
component.orgId = '123';
const spy = spyOn(component.sharedService, 'setOrganizationId');
const navigateSpy = spyOn((component as any).route, 'navigate');
component.onViewUser();
expect(spy).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalled();
expect(component.userData.length).toEqual(0);
expect(component.submitted).toBeFalsy();
expect(component.userCount).toBeLessThan(15);
expect(component.undefinedVariable).toBeUndefined();
});

it('should navigate to add user page', () => {
const navigateSpy = spyOn((component as any).route, 'navigate');
component.onViewUser();
expect(navigateSpy).toHaveBeenCalled();
expect(component.userData.length).toEqual(1);
expect(component.userData).toEqual([{name: 'ZYMR'}]);
expect(component.submitted).toBeTruthy();
expect(component.userCount).toBeGreaterThan(15);
expect(component.undefinedVariable).toBeDefined();
});

describe('on step click', () => {
let spyRoute: jasmine.Spy;
beforeEach(waitForAsync(() => {
spyRoute = spyOn((component as any).route, 'navigate');
}));
it('should be navigate to first main info step with event id', () => {
component.completedSteps = [1,2];
component.currentStep = 2;
component.orgId = '10';
component.navigateToStep(1);
expect(spyRoute).toHaveBeenCalledWith([AppRoutes.user + component.orgId]);
});
it('should be navigate to second event detail step with event id', () => {
component.completedSteps = [1,2];
component.currentStep = 1;
component.orgId = '10';
component.navigateToStep(2);
expect(spyRoute).toHaveBeenCalledWith([AppRoutes.organization + component.orgId]);
});
it('should be navigate to third particiant step with event id', () => {
component.completedSteps = [1,2,3];
component.currentStep = 1;
component.orgId = '10';
component.navigateToStep(3);
expect(spyRoute).toHaveBeenCalledWith([AppRoutes.userPath + component.orgId]);
});
it('should be navigate to fourth communication step with event id', () => {
component.completedSteps = [1,2,3,4];
component.currentStep = 3;
component.orgId = '10';
component.navigateToStep(4);
expect(spyRoute).toHaveBeenCalledWith([AppRoutes.addUser + component.orgId]);
});
it('should not navigate to any step', () => {
component.completedSteps = [1,2,3,4,5];
component.currentStep = 3;
component.orgId = null;
component.navigateToStep(5);
expect(spyRoute).not.toHaveBeenCalled();
});
});
});

export const rejectPromise = (msg: any) => defer(() => Promise.reject(msg));
export const promiseData = (data: T) => defer(() => Promise.resolve(data));

因此,在此组件中,您可以看到您有一个测试列表,其中列出了以下内容:

2. Angular 响应式表单验证

3.条件变量和路由调用

4.多case覆盖分支和statement

5.弹出模态以是和否关闭

6.提交表单数据和调用post API以及错误处理部分将覆盖

7. 使用 API 调用获取组织详细信息

所以所有这些东西,您将在我们的规范文件中进行测试。您可以在上图中看到,它将涵盖所有语句、函数、分支等等。

从上面的代码片段中,您会注意到很多事情。下面解释了其中每一个的作用: 

  1. 我们使用 adescribe开始我们的测试,我们给出了我们在其中测试的组件的名称。
  2. 我们可以在执行每个规范之前执行一些代码。此功能有利于在应用程序中运行公共代码。
  3. 在里面beforeEach,我们有TestBed.ConfigureTestingModuleTestBed设置配置并初始化适合我们的环境test.ConfigureTestingModule设置允许我们测试组件的模块。您可以说它为我们的测试环境创建了一个模块,并在其中包含声明、导入和提供程序。

检测服务

1.Organization.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HttpBaseService } from '../shared/services/httpbase/httpbase.service';
import { LanguageService } from '../shared/services/language/language.service';

@Injectable({
providedIn: 'root'
})
export class OrganizationService extends HttpBaseService {
constructor(httpClient: HttpClient, languageService: LanguageService) {
super(httpClient, languageService);
}

prepareOrganizationListResponse(resList: any[]) {
let organizationList: any = [];
let organization: any = {};

resList.forEach(list => {
organization.lastName = list.lastName,
organization.firstName = list.firstName,
organization.email = list.email,
organization.isActive = list.isActive

organizationList.push(organization);
});
return organizationList;
}

prepareOrganizationDetailsResponse(res: any) {
return {
lastName: res.lastName,
firstName: res.firstName,
email: res.email,
isActive: res.isActive
};
}
}

2. Organization.service.spec.ts 

import { SharedModule } from 'src/app/shared/modules/shared.module';
import { HttpClientModule } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule, TranslateLoader, TranslateFakeLoader, TranslateService } from '@ngx-translate/core';
import { HttpBaseService } from '../shared/services/httpbase/httpbase.service';
import { SharedService } from '../shared/services/shared.service';
import { OrganizationService } from './organization.service';
import { OrganizationConfigurationApi, OrganizationListItemUI } from './organization.model';

describe('OrganizationService', () => {
let service: OrganizationService;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientModule,
SharedModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateFakeLoader
}
}),
],
providers: [
TranslateService,
HttpBaseService,
SharedService,
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: MatDialogRef, useValue: {} }
]
}).compileComponents();
service = TestBed.inject(OrganizationService);
});

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

it('should be return properly formatted organization list response', () => {
let organization: any = {};
organization.lastName = 'lastName',
organization.firstName = 'firstName',
organization.email = '[email protected]',
organization.isActive = true,

expect(service.prepareOrganizationListResponse(
[
{
lastName: 'lastName',
firstName: 'firstName',
email: '[email protected]',
isActive: true,
}
]
)).toEqual([organization]);
});

it('should be return organization details response', () => {
expect(service.prepareOrganizationDetailsResponse({
lastName: 'lastName',

你可能感兴趣的:(杂文,前端)