Functional Route Guards in Angular

Published on Apr 27, 2023

tldr;

In Angular v14.2, functional route guards were introduced as a new way to protect parts of Angular applications. Previously, the only way to write route guards was by using class based guards. The class based guards worked fine, but required a lot of extra setup to be used.

Functional route guards require just a single function, and can be written in a separate file, or created inline for a route if needed. In this article, I’ll show a couple examples of route guards, but more importantly I’ll show you how to create tests for your functional route guards. As I was working on converting my class based guards to functional, I could not find much information on how to test the guards. Hopefully this will help serve as a guide for others.

Writing the Functional Guards

In many ways, writing functional route guards is way easier than it was previously. You don’t have to remember to implement an interface, or anything like that. All it requires is a function which returns a function. The inner function is of a given type, like CanMatchFn or CanActivateFn for example. However, you don’t necessarily need to know what type of function you are returning. Here’s an example of a very simple guard that can be used as a canActivate guard:

export function authenticationGuardArrow = () => inject(AuthService).isAuthenticated()

In this guard, we are injecting the AuthService and calling the isAuthenticated method to see if the user can continue on to the designated route. Notice that in this case there is no explicit definition of what type of function is returned from the inner method on the guard.

Here’s a more explicit version of an authentication guard:

export function authenticationGuard(): CanActivateFn {
  return () => {
    const oauthService: AuthService = inject(AuthService);

    if (oauthService.hasAccess() ) {
      return true;
    }

    oauthService.login();

    return false;
  };
}

In this version of the guard, we check to see if the user has access. If they do, we continue on to the route. If the user doesn’t have access, they are prevented from going to the route and the login flow is begun. The CanActivateFn has two optional parameters, ActivatedRouteSnapshot and RouterStateSnapshot. Because I am not using those parameters, they are not included.

Another way that functional route guards are easier to use as well is when you need to pass some data to the route guard. Take this feature flag guard for example, where it needs to know what flag to check and where to redirect them to if needed:

export function featureFlagGuard(
  flagName: string,
  redirectRoute: string
): CanActivateFn {
  return () => {
    const featureFlagsService: FeatureFlagsService =
      inject(FeatureFlagsService);
    const router: Router = inject(Router);

    const isFlagEnabled = featureFlagsService.isFeatureEnabled(flagName);

    return isFlagEnabled || router.createUrlTree([redirectRoute]);
  };
}

In this case, the featureFlagGuard takes two arguments, the flag to check and the redirect route. If the flag is enabled, the user can continue to the route. If not they are redirected to the provided route.

Previously, with class based routes, this type of guard was only possible if you passed the parameters on the route data object. This is much simpler and it is more obvious what is going on.

Adding Functional Route Guards to Routes

Once you’ve written your functional route guard, you need to apply it to a given route. The good thing is that this is essentially the same as with class based routes. There is a minor difference though. If your function looks like the authenticatonGuardArrow function above, it can be added to a route like this:

const routes: Route[] = [
  { path: 'home', component: HomeComponent, canActivate: [authenticationGuardArrow]}
]

In this example, it looks pretty much the same as before. The guard is just added to the canActivate array. Because the guard returns an arrow function, the guard is called for you automatically. However, with the other two guards above, you need to explicitly call the function.

const routes: Route[] = [
  { path: 'home', component: HomeComponent, canActivate: [authenticationGuard()]},
  { path: 'feature', component: HomeComponent, canActivate: [featureFlagGuard('checkFlag', '/home')]}
]

In this example, we add the guards to the components in the same way as before, but we have to invoke the function for them to actually run.

Just like with class based guards, these new functional guards can return booleans, UrlTrees, Promises, and Observables.

Testing Your Route Guards

The next step after writing your guard, you need to add some tests for the guard. This is where I got stuck a little and hope to clear things up for others. These tests do require Angular v15.2 due to the use of the new RouterTestingHarness.

The new RouterTestingHarness allows your tests to run as if there is a real router initiated, and gives you the ability to declare routes and see if the guard allows you to reach certain routes or not. It requires the use of TestBed, which I generally don’t use when writing Angular unit tests, but is actually pretty smooth and easy to use.

I will provide here some examples of the tests I wrote for the above guards, which can be used as references.

// authentication.guard.spec.ts

@Component({ standalone: true, template: "" })
class DashboardComponent {}

describe("AuthenticationGuard", () => {
  let routes: Route[];

  beforeEach(() => {
    routes = [
      {
        path: "dashboard",
        canActivate: [AuthenticationGuard()],
        component: DashboardComponent,
      },
    ];
  });

  it("should initiate the login flow if there is no valid token", async () => {
    const mockOAuthService = {
      hasValidAccessToken: jest.fn().mockReturnValue(false),
      initCodeFlow: jest.fn(),
    };
    const mockAuthRedirectService = { saveRoute: jest.fn() };

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        { provide: OAuthService, useValue: mockOAuthService },
        { provide: AuthRedirectService, useValue: mockAuthRedirectService },
        provideRouter(routes),
      ],
    });

    await RouterTestingHarness.create("/dashboard");
    expect(mockOAuthService.initCodeFlow).toHaveBeenCalled();
    expect(mockAuthRedirectService.saveRoute).toHaveBeenCalledWith(
      "/dashboard"
    );
  });

  it("should allow access to the dashboard if the token is valid", async () => {
    const mockOAuthService = {
      hasValidAccessToken: jest.fn().mockReturnValue(true),
      initCodeFlow: jest.fn(),
    };
    const mockAuthRedirectService = { saveRoute: jest.fn() };

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        { provide: OAuthService, useValue: mockOAuthService },
        { provide: AuthRedirectService, useValue: mockAuthRedirectService },
        provideRouter(routes),
      ],
    });

    await RouterTestingHarness.create("/dashboard");
    expect(TestBed.inject(Router).url).toEqual("/dashboard");
  });

  it("should allow access to the dashboard if the token is not valid but there is a code query param", async () => {
    const mockOAuthService = {
      hasValidAccessToken: jest.fn().mockReturnValue(false),
      initCodeFlow: jest.fn(),
    };
    const mockAuthRedirectService = { saveRoute: jest.fn() };

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        { provide: OAuthService, useValue: mockOAuthService },
        { provide: AuthRedirectService, useValue: mockAuthRedirectService },
        provideRouter(routes),
      ],
    });

    await RouterTestingHarness.create("/dashboard?code=1234");
    expect(TestBed.inject(Router).url).toEqual("/dashboard?code=1234");
  });
});

And then here are the tests for the feature flag guard:

@Component({ standalone: true, template: "" })
class AdminComponent {}
@Component({ standalone: true, template: "" })
class LoginComponent {}

describe("FeatureFlagGuard", () => {
  let routes: Route[];
  let httpMock: HttpTestingController;

  beforeEach(() => {
    routes = [
      {
        path: "test",
        canActivate: [FeatureFlagGuard("test", "/")],
        component: AdminComponent,
      },
      { path: "home", component: LoginComponent },
    ];
  });

  it("should route back to the home route if the flag is not present and it defaults to off", async () => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
        SharedUtilitiesFeatureFlagsModule.forRoot({
          defaultToFlagOff: true,
          jsonUrl: "/assets/test.config.json",
        }),
      ],
      providers: [provideRouter(routes)],
    });
    httpMock = TestBed.inject(HttpTestingController);
    const mockRequest = httpMock.expectOne("/assets/test.config.json");
    mockRequest.flush({});

    await RouterTestingHarness.create("/test");
    expect(TestBed.inject(Router).url).toEqual("/");
  });

  it("should route to the test route if the flag is not present and it does not default to off", async () => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
        SharedUtilitiesFeatureFlagsModule.forRoot({
          defaultToFlagOff: false,
          jsonUrl: "/assets/test.config.json",
        }),
      ],
      providers: [provideRouter(routes)],
    });
    httpMock = TestBed.inject(HttpTestingController);
    const mockRequest = httpMock.expectOne("/assets/test.config.json");
    mockRequest.flush({});

    await RouterTestingHarness.create("/test");
    expect(TestBed.inject(Router).url).toEqual("/test");
  });

  it("should route to the test route if the flag is turned on", async () => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
        SharedUtilitiesFeatureFlagsModule.forRoot({
          defaultToFlagOff: true,
          jsonUrl: "/assets/test.config.json",
        }),
      ],
      providers: [provideRouter(routes)],
    });
    httpMock = TestBed.inject(HttpTestingController);
    const mockRequest = httpMock.expectOne("/assets/test.config.json");
    mockRequest.flush({ test: true });

    await RouterTestingHarness.create("/test");
    expect(TestBed.inject(Router).url).toEqual("/test");
  });

  it("should not route to the test route if the flag is turned off", async () => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
        SharedUtilitiesFeatureFlagsModule.forRoot({
          defaultToFlagOff: true,
          jsonUrl: "/assets/test.config.json",
        }),
      ],
      providers: [provideRouter(routes)],
    });
    httpMock = TestBed.inject(HttpTestingController);
    const mockRequest = httpMock.expectOne("/assets/test.config.json");
    mockRequest.flush({ test: false });

    await RouterTestingHarness.create("/test");
    expect(TestBed.inject(Router).url).toEqual("/");
  });
});

These tests demonstrate the configuration of routing being set up for the test, as well as for providing mock values for injected services when needed. You can also see examples of how to test if the guard worked or did not work.

Having good test coverage and being able to ensure your guards are working as expected is vital to the maintenance of your app. I hope this assists others in their applications.

Red Flags

One red flag to be aware of, which I ran into, is to ensure that the inject method is located in the correct place. It needs to be used inside the inner return method, and not the outer:

export function testGuard {
  // don't use inject here

  return () => {
    // use inject here
  }
}

If you use inject in the outer function, you will see an error like this:

Untitled

Conclusion

I wasn’t really sure what the big deal was for the introduction of functional route guards, but I do think I prefer this method of writing the guards. The thing is, it doesn’t matter if I prefer this method or not, because the class based routers are being deprecated. Functional route guards will be the only way forward in the near future. I suggest getting a head start on converting your guards, and hopefully this article will assist you in that work.