在继续操作以前,先修正下学生实体。由于我们前面的误操作,在进行学生实体实始化时,忘记加入班级信息了。下面,我们来共同修正一下学生实体。 norm/entity/student.ts ``` @NgModule({}) export class Student { id: number; klass: Klass; ➊ name: string; sno: string; constructor(data?: { id?: number; klass?: Klass ➊; name?: string; sno?: string }) { if (!data) { return; } this.id = data.id ? data.id : null; this.klass = data.klass ? data.klass : null; ➊ this.name = data.name ? data.name : ''; this.sno = data.sno ? data.sno : ''; } } ``` * ➊ 加入了班级信息,幸运的是由于我们使用了更加优化的构造函数。我们刚刚的操作对历史代码没有造成任何影响。 # MVC 在上个小节中,大家使用常规的方法完成新增学生组件的功能,现在我们使用MVC的思想重新开发一遍。 我们刚刚接触了直接与用户进行交互的V层 -- add.component.html,以及与V层进行直接交互的C层 -- add.component.ts。按MVC的开发思想:V层负责响应用户;C层负责接收数据接收、数据较验、数据转发;M层则负责逻辑处理。回想下3.6.2删除班级的小节,我们在后台进行班级删除时也正是这么做的。 在使用Angular进行开发时,我们也应该将逻辑处理由C层中剥离出来,进而提升代码的可读性、降低软件维护的成本。当前我们需要一个学生服务来完成学生新增的逻辑功能。 写代码之前,我们先简单画个图,这样自己编写或是与团队成员交流的时候会更清晰: ![](https://img.kancloud.cn/56/8e/568e7f03b564e6cd152dbb4fee1e0523_497x304.png) * 方法前面的序号代码执行顺序 * ➊ 方法名 * ➋ 输入参数 * ➌ 返回值 # M层初始化 与后台的开发思路一致:我们在app目录下新建service子目录 -> 在该目录下使用angular-cli生成student服务。 ``` panjiedeMac-Pro:app panjie$ mkdir service panjiedeMac-Pro:app panjie$ cd service/ panjiedeMac-Pro:service panjie$ ng g s student CREATE src/app/service/student.service.spec.ts (338 bytes) CREATE src/app/service/student.service.ts (136 bytes) ``` 自动生成的代码如下所示: ``` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' ➊ }) export class StudentService { constructor() { } } ``` ➊ 声明被注入范围为`root`,即整个系统。此时我们可以在整个项目像注入这个服务,比如我们需要在student/add/add.component.ts中注入这个服务,则可以直接在该文件中这么写: ``` constructor(private studentService: StudentService) { } ``` ## 增加SAVE方法 在MVC的思想,我们将原来在C层中进行的请求后台的操作转移到StudentService的save方法中。 service/student.service.ts ``` @Injectable({ providedIn: 'root' }) export class StudentService { constructor(private httpClient: HttpClient) { } /** * 保存学生 * 直接调用HttpClient post方法 * @param student 学生 * @return 此返回值是个可观察对象: * 1. 其它人可以通过 订阅 操作来获取该对象后续发送的值。 * 2. 该对象如果发送值,那么该值的类型必然是Student。 */ save(student: Student): Observable<Student> { const url = 'http://localhost:8080/Student'; return this.httpClient.post<Student➊>(url, student); } } ``` 我们往往与新增教师、新增班级时没有返回值不同,在定义新增学生接口时我们定义了其返回值为Student,也就是说后台需要将持久化后的学生再返回给前台。this.httpClient.post的功能是发请一个http post请求,其返回值的类型取决于后台具体返回的类型,也就是说:该方法的返回类型不定,但必然应该有一个类型(哪怕是void),而泛型就恰到好处的可以实现这一点。我们使用➊来规定此this.httpClient.post发送的请求的返回值类型为Student。 ## 单元测试 和测试组件的方法一致,我们来到service/student.service.spec.ts,并对自动生成的文件进行小幅重构。 ``` import {TestBed} from '@angular/core/testing'; import {StudentService} from './student.service'; import {HttpClientTestingModule} from '@angular/common/http/testing'; fdescribe('service -> StudentService', () => { let service: StudentService; ➊ beforeEach(() => TestBed.configureTestingModule({ imports: [HttpClientTestingModule] ① })); beforeEach(() => { ➋ service = TestBed.get(StudentService); ➌ }); it('should be created', () => { expect(service).toBeTruthy(); }); it('save', () => { ➍ }); }); ``` * ➊ 将公共的对象(每个测试用例都会用到)向上抽离 * ➋ 在每个测试用例执行前,本方法内的语句均执行1次 * ➌ 在每个测试用例前,均重新获取一个StudentService * ➍ 新建测试用例测试save方法 ### 完善测试功能 在写单元测试以前,我们必须要弄清两个问题:输入与输出。在save方法中,输入的为Teacher,输出的为一个**可观察者**。如果想确认这个**可观察者**发送的数据是否符合我们的预期,则要进行**订阅**操作。我们按模拟输入、调用方法、断言输出的步骤来编写以下测试代码: ``` /** * 测试新增 * 1. 初始化测试数据 * 2. 调用保存方法并进行订阅 * 2.1 断言响应中返回了学生ID信息 * 3. 断言发起了HTTP POST请 * 4. 断言请求数据 * 5. 模拟HTTP响应数据 * 6. 断言订阅的方法被调用 */ it('save', () => { const student: Student = new Student( { name: 'test', klass: new Klass(1, null, null) }); let called = false; service.save(student).subscribe①((returnStudent: Student) => { called = true; ③ expect(returnStudent.id).toBe(-1); }); const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne('http://localhost:8080/Student'); expect(req.request.method).toEqual('POST'); const studentBody: Student = req.request.body.valueOf(); expect(studentBody.name).toEqual(student.name); expect(studentBody.klass.id).toEqual(student.klass.id); req.flush(new Student({id: -1})); ② expect(called).toBe(true); ④ }); ``` * 程序执行顺序 ①②③④ ![](https://img.kancloud.cn/dd/93/dd93fb1475906ed9cfbfdcdd89452e80_383x113.png) 测试通过,说明符合预期,M层开发完毕。 # C层 由于StudentService声明的被注入范围为root,所以我们可以在直接在student/add/add.component.ts中注入该服务。 ``` export class AddComponent implements OnInit { student: Student; formGroup: FormGroup; constructor(private studentService: StudentService①) { } ``` * ① 和注入其它协作者的方法一样。 ## 调用M层 然后在需要的方法中直接进行相关调用: student/add/add.component.ts ``` onSubmit(): void { this.student = this.formGroup.value; this.studentService.save(this.student).subscribe★((student: Student) => {① console.log(student); }); } ``` * ★ 必须进行订阅,否则HttpClient将不会发起POST请求。 HttpClient这个被订阅者有点意思,它像极了现实社会中的房地产商。几年前的房地产商拿到地以后,下一步就是做模型画大饼来告知老百姓:我将要盖一个什么样的房子,然后价格是多少。如果没有用户愿意购买,那么前面就会一直停留计划之中;只有当用户真真切切的交了钱,房地产商才会真真正正的去盖楼。现实社会中这无形的助长了地价的飙升,增加了购房人面临的延期交房或是不交房的风险。但在计算机的世界时,这却不失为一种最佳的解决方案:HttpClinet.post方法只是表明将进行一个post请求,而如果没有人想知道请求结果的话,那我一直不会发起真实请求,只有当有人订阅了它表明想获取请求结果时,它才会真真切切的去发起这个HTTP请求。所以如果要保证该请求真实发生,必须对其进行订阅。 ## 单元测试 由于我们在组件中引入了UserService,而在UserService又引入了HttpClient,所以执行原单元测试将会报没有HttpClient的提供者错误。 ![](https://img.kancloud.cn/7f/f5/7ff5e631195a4f088e5bcddbdf60634c_194x292.png) ``` NullInjectorError: StaticInjectorError(DynamicTestModule)\[HttpClient\]: StaticInjectorError(Platform: core)\[HttpClient\]: NullInjectorError: No provider for HttpClient! ``` 与解决其它的此类问题的方法相同,我们在单元测试中引入HttpClientTestingModule。 ``` beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent], imports: [ ReactiveFormsModule, HttpClientTestingModule ① ], }) .compileComponents(); })); ``` ### 增加测试内容 为了确保当用户添加写相关的内容后点击保存按钮发起我们预期的请求,我们对以前的测试代码进行以下补充。 ``` /** * 1. 向表单中输入值 * 2. 点击保存按钮 * 3. 断言输入的值传入到了C层 */ fit('should create', () => { expect(component).toBeTruthy(); formTest.setInputValue('input[name="name"]', 'testname'); formTest.setInputValue('input[name="sno"]', 'testno'); formTest.clickButton('button[type="submit"]'); fixture.detectChanges(); expect(component.student.name).toEqual('testname'); expect(component.student.sno).toEqual('testno'); savePostTest(); ① }); /** * 断言发起了相关请求 * 断言在请求的中接收到了对应的值 */ const savePostTest = (): void => { ② const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne('http://localhost:8080/Student'); expect(req.request.method).toEqual('POST'); const student: Student = req.request.body.valueOf(); expect(student.name).toEqual('testname'); expect(student.sno).toEqual('testno'); }; ``` 由于我们在组件中订阅返回内容后,仅仅是进行控制台打印操作,所以未对组件订阅后的内容进行测试。 ![](https://img.kancloud.cn/c0/f0/c0f0656945a7881b4e285e539af8da9c_790x116.png) 测试通过。 # 加入选择班级组件 最后让我们加入选择班级组件 ## V层 student/add/add.component.html ``` <label>班级:<app-klass-select (selected)="onSelectKlass($event)"></app-klass-select></label> ``` ## C层 student/add/add.component.ts ``` export class AddComponent implements OnInit { ... klass: Klass; ✚ constructor(private studentService: StudentService) { } ngOnInit() { this.student = new Student(); this.formGroup = new FormGroup({ name: new FormControl(''), sno: new FormControl('') }); } onSelectKlass(klass: Klass): void { ✚ this.klass = klass; } onSubmit(): void { this.student = this.formGroup.value; this.student.klass = this.klass; ✚ this.studentService.save(this.student).subscribe((student: Student) => { console.log(student); }); } ``` ## 单元测试 在当前的测试思路下,初始化单元测试时必须先要弄清各个模块前的依赖关系。 ![](https://img.kancloud.cn/27/20/27205f7bfc69c662809faca688b9f03a_356x370.png) 按上述依赖图,我们需要如下定制测试文件: ``` beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent, KlassSelectComponent①], imports: [ ReactiveFormsModule, HttpClientTestingModule, CoreModule ② ], }) .compileComponents(); })); ``` * ① 同模块的该组件加入到declarations中 * ② 不同模块的将组件所在的模块加入到imports中 ### 增加测试内容 student/add/add.component.spec.ts ``` expect(component).toBeTruthy(); component.klass = new Klass(-1, null, null); ✚ formTest.setInputValue('input[name="name"]', 'testname') ... const savePostTest = (): void => { ... expect(student.sno).toEqual('testno'); expect(student.klass.id).toEqual(-1); ✚ }; ``` ![](https://img.kancloud.cn/12/ad/12ad9f23d9a26daabd4ac00c9d491b89_410x138.png) ## 整体测试 最后,将特定方法上的`f`去除,进行整个项目的单元测试. ![](https://img.kancloud.cn/11/46/1146b85e30d0d3d083e2c4ed8ea20321_462x153.png) # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.5) | \- |