Angular中Auth0服务Jasmine单元测试无覆盖率问题排查
问题排查
你单元测试无覆盖率的核心原因有3个:
- 测试对象替换错误:你把需要被测试的
AuthService直接替换成了自己写的Auth0SecurityServiceStub,测试全程跑的都是存根代码,原服务的代码完全没有被执行,自然没有覆盖率。 - 存根不符合Auth0原生API规范:
auth0.WebAuth的parseHash是回调式API,不会直接返回结果,你写的存根直接返回对象,就算你不替换AuthService,原服务内部的parseHash回调逻辑也完全不会触发,核心流程跑不通。 - 无分支覆盖设计:你没有针对
handleLoginCallback的错误分支、getUserInfo的元数据判断分支、角色判断分支、authenticated的false分支做测试设计,就算主流程跑通也拿不到全量覆盖率。
全覆盖率测试实现方案
测试环境配置
你需要保留真实的AuthService作为被测对象,仅Mock它的外部依赖即可,以下是Angular+Jasmine环境下的测试模块配置示例:
import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { Router } from '@angular/router'; import { AuthService } from './auth.service'; import * as auth0 from 'auth0-js'; import jwt_decode from 'jwt-decode'; describe('AuthService', () => { let service: AuthService; let routerSpy: jasmine.SpyObj<Router>; let auth0Mock: any; let userInfoSpy: jasmine.Spy; beforeEach(() => { // Mock Router const mockRouter = jasmine.createSpyObj('Router', ['navigate']); // Mock Auth0 WebAuth 实例,符合原生API规范 userInfoSpy = jasmine.createSpy('userInfo').and.callFake((accessToken, callback) => { callback(null, { name: 'test user' }); }); auth0Mock = { authorize: jasmine.createSpy('authorize'), parseHash: jasmine.createSpy('parseHash'), logout: jasmine.createSpy('logout'), client: { userInfo: userInfoSpy } }; // Spy 掉 auth0.WebAuth 构造函数,返回我们的mock实例 spyOn(auth0, 'WebAuth').and.returnValue(auth0Mock); // Spy jwt_decode spyOn(window, 'jwt_decode').and.returnValue({ "https://api.test.net/roles": ["COMMON_EXCEPTIONHANDLER_UPDATER"] }); // 清理localStorage避免用例污染 localStorage.clear(); TestBed.configureTestingModule({ providers: [ AuthService, { provide: Router, useValue: mockRouter } ] }); service = TestBed.inject(AuthService); routerSpy = TestBed.inject(Router) as jasmine.SpyObj<Router>; });
全分支测试用例
接下来按照覆盖所有方法、所有分支的原则写测试用例:
// 测试login方法 it('login 应该调用auth0的authorize方法', () => { service.login(); expect(auth0Mock.authorize).toHaveBeenCalledWith({ connection: 'value ....' }); }); // 测试handleLoginCallback 成功分支 it('handleLoginCallback 拿到有效authResult时应该执行登录成功逻辑', fakeAsync(() => { const mockAuthResult = { accessToken: 'test-token', expiresIn: 3600, idTokenPayload: { user_metadata: { username: 'test' }, name: 'test.com' } }; // 让parseHash调用成功回调 auth0Mock.parseHash.and.callFake((callback) => { callback(null, mockAuthResult); }); spyOn(service, 'getUserInfo'); service.handleLoginCallback(); tick(); expect(window.location.hash).toBe(''); expect(service.accessToken).toBe('test-token'); expect(service.getUserInfo).toHaveBeenCalledWith(mockAuthResult); expect(routerSpy.navigate).toHaveBeenCalledWith(['/home']); })); // 测试handleLoginCallback 错误分支 it('handleLoginCallback 拿到错误时应该打印错误日志', () => { const mockErr = { error: 'login failed' }; auth0Mock.parseHash.and.callFake((callback) => { callback(mockErr, null); }); spyOn(console, 'error'); service.handleLoginCallback(); expect(console.error).toHaveBeenCalledWith(`Error: ${mockErr.error}`); }); // 测试handleLoginCallback 无authResult也无错误分支 it('handleLoginCallback 无有效authResult时应该打印提示', () => { auth0Mock.parseHash.and.callFake((callback) => { callback(null, null); }); spyOn(console, 'log'); service.handleLoginCallback(); expect(console.log).toHaveBeenCalledWith('Authentication not successful'); }); // 测试getUserInfo 有用户元数据且有对应角色分支 it('getUserInfo 有用户元数据时应该设置权限和用户信息', () => { const mockAuthResult = { accessToken: 'test-token', idTokenPayload: { user_metadata: { username: 'test' }, name: 'test.com' } }; service.getUserInfo(mockAuthResult); expect(userInfoSpy).toHaveBeenCalledWith('test-token', jasmine.any(Function)); expect(localStorage.getItem('perm')).toBe('write'); expect(localStorage.getItem('loggedInUser')).toBe('test@test.com'); }); // 测试getUserInfo 无用户元数据分支 it('getUserInfo 无用户元数据时不设置本地存储', () => { const mockAuthResult = { accessToken: 'test-token', idTokenPayload: {} }; service.getUserInfo(mockAuthResult); expect(localStorage.getItem('perm')).toBeNull(); expect(localStorage.getItem('loggedInUser')).toBeNull(); }); // 测试_setSession方法 it('_setSession 应该正确设置登录状态', () => { const mockAuthResult = { accessToken: 'test-token', expiresIn: 3600 }; const mockProfile = { name: 'test user' }; const now = Date.now(); spyOn(Date, 'now').and.returnValue(now); spyOn(service['loggedIn$'], 'next'); service['_setSession'](mockAuthResult, mockProfile); expect(service.expiresAt).toBe(3600 * 1000 + now); expect(service.accessToken).toBe('test-token'); expect(service.userProfile).toEqual(mockProfile); expect(service['loggedIn$'].next).toHaveBeenCalledWith(true); expect(service.loggedIn).toBeTrue(); }); // 测试logout方法 it('logout 应该调用auth0登出并清理本地存储', () => { localStorage.setItem('loggedInUser', 'test@test.com'); service.logout(); expect(auth0Mock.logout).toHaveBeenCalled(); expect(localStorage.getItem('loggedInUser')).toBeNull(); }); // 测试authenticated getter分支 it('authenticated 未过期且已登录时返回true', () => { service['expiresAt'] = Date.now() + 3600 * 1000; service['loggedIn'] = true; expect(service.authenticated).toBeTrue(); }); it('authenticated 已过期时返回false', () => { service['expiresAt'] = Date.now() - 1000; service['loggedIn'] = true; expect(service.authenticated).toBeFalse(); }); it('authenticated 未登录时返回false', () => { service['expiresAt'] = Date.now() + 3600 * 1000; service['loggedIn'] = false; expect(service.authenticated).toBeFalse(); }); });
额外优化建议
如果想让测试更易维护,可以把auth0.WebAuth的实例改成依赖注入的方式,不用在AuthService内部直接new,后续Mock不需要spy构造函数,配置更简单。
内容的提问来源于stack exchange,提问作者user2273499
相关产品推荐
相关产品推荐

