DEV Community

loading...
Cover image for Angular Environment Setup - Safe & Testable
Angular

Angular Environment Setup - Safe & Testable

n_mehlhorn profile image Nils Mehlhorn Originally published at nils-mehlhorn.de Updated on ・5 min read

Originally published at nils-mehlhorn.de

Most real-world Angular applications live in different environments throughout their development cycle. While differences generally should be kept to a minimum, your webapp is probably supposed to behave a little bit different on a developer's machine compared to when it's deployed to production.

Angular already has a solution for this called environments. To recap how they work: you place an arbitrary number of environment files in a directory such as src/environments like so:

src
└── environments
    ├── environment.prod.ts
    ├── environment.stage.ts
    └── environment.ts
Enter fullscreen mode Exit fullscreen mode

Any non-default environments are suffixed correspondingly, for example with 'prod' for your production environment.

Inside of every file you'll export an object called environment defining the same properties just with environment-specific values. This could be a boolean flag indicating a production environment or the environment's name:

// environment.ts
export const environment = {
  production: false,
  name: 'dev',
  apiPath: '/api'
}
Enter fullscreen mode Exit fullscreen mode
// environment.stage.ts
export const environment = {
  production: false,
  name: 'stage',
  apiPath: '/stage/api'
}
Enter fullscreen mode Exit fullscreen mode
// environment.prod.ts
export const environment = {
  production: true,
  name: 'prod',
  apiPath: '/prod/api'
}
Enter fullscreen mode Exit fullscreen mode

Now in order to let the application use a different environment for different builds, you'll define a build configuration for each environment inside your angular.json. There you'll configure a file replacement which will switch environment.ts for a specific override such as environment.prod.ts like so:

"architect": {
  ...
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {...},
    "configurations": {
      "production": {
        "fileReplacements": [{
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }],
        ...
      }
      "stage": {
        "fileReplacements": [{
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.stage.ts"
        }],
        ...
      }
    }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

When building, you'll trigger a configuration by passing it's name to the Angular CLI:

ng build --configuration <config>
Enter fullscreen mode Exit fullscreen mode

Hint: when you're using ng build --prod it'll pick the configuration called 'production'.

That's actually it: file replacements and plain JavaScript objects - not too much Angular magic. Now you'd just import from environment.ts and always get the environment-specific properties during runtime:

import { environment } from '../environments/environment';

// ng build             --> 'dev'
// ng build -c stage    --> 'stage'
// ng build --prod      --> 'prod'
console.log(environment.name)
Enter fullscreen mode Exit fullscreen mode

But we can do better. There's two problems I encountered with this setup:

  1. When adding new properties to environment.ts it's easy to forget adding counterparts in the other environment files
  2. You can't perform environment specific tests

Let's solve these issues with two changes to our setup.

Typing the Environment

Angular means TypeScript, so why not profit from the languages benefits here? By typing our environment we get notified by the compiler when any of our environments are missing properties. To do so, we'll define an interface for our environment in a file called ienvironment.ts:

export interface Environment {
  production: boolean
  name: string
  apiPath: string
}
Enter fullscreen mode Exit fullscreen mode

Now, when defining environment objects we'll declare their types to be of our newly created interface:

import {Environment} from './ienvironment'

export const environment: Environment = {
  production: false,
  name: 'dev',
  apiPath: '/api'
}
Enter fullscreen mode Exit fullscreen mode

Do this in all your environment files and you'll greatly benefit from the type system. This way you won't get any surprises when deploying a new environment-related feature.

Testing with Environments

Sometimes I found myself in situations where I'd wanted to perform environment-specific tests. Maybe you'd have an error handler that should only log to the console in a development environment but forward errors to a server during production. As environments are simply imported it is inconvenient to mock them during test execution - let's fix that.

The Angular architecture is based on the principle of dependency injection (DI). This means that a class (e.g. a component or service) is provided with everything it needs during instantiation. So any dependencies are injected by Angular into the class constructor. This allows us to switch these dependencies for mocked counterparts during testing.

When providing our environment through dependency injection, we'll be able to easily mock it for environment-specific test cases. For this we create another file environment.provider.ts where we define an InjectionToken. Usually Angular uses the class name to identify a dependency, but since our environment only has a TypeScript interface (which will be gone at runtime) we need to provide such a token instead. Additionally, since Angular cannot call an interface's constructor, we provide a factory method to get the environment instance. Eventually, our provider code looks like this:

import {InjectionToken} from '@angular/core'
import {Environment} from './ienvironment'
import {environment} from './environment'

export const ENV = new InjectionToken<Environment>('env')

export function getEnv(): Environment {
  return environment;
}
Enter fullscreen mode Exit fullscreen mode

Then we'll pass this provider to our Angular module by adding it to the providers list:

import {ENV, getEnv} from '../environments/environment.provider'

@NgModule({
  ...
  providers: [
    {provide: ENV, useFactory: getEnv}
  ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Now, instead of importing from environment.ts directly we'll inject the environment into any class that needs access to it by using the Inject decorator.

import { Injectable, Inject } from '@angular/core';
import { Environment } from '../environments/ienvironment'
import { ENV } from '../environments/environment.provider'

@Injectable() 
export class UserService {

  constructor(@Inject(ENV) private env: Environment) {
  }

  save(user: User): Observable<User> {
      if (this.env.production) {
        ...
      } else {
        ...
      }
  }

}
Enter fullscreen mode Exit fullscreen mode

In order to mock our environment during test we can now easily pass a counterpart directly into the class constructor or provide it through Angular's dependency injection using the TestBed like this:

import { ENV } from '../environments/environment.provider'

describe('UserService', () => {
  describe('when in production', () => {
      beforeEach(() => {
        const env = {production: true, ...}
        // without TestBed
        const service = new UserService(env)
        // or with TestBed
        TestBed.configureTestingModule({
          providers: [
            {provide: ENV, useValue: env}
          ]
        });
      });
  });
});
Enter fullscreen mode Exit fullscreen mode

Also, if you'd like to enforce that the environment is used through dependency injection, you might even create a tslint rule blocking direct imports preventing unintended usage.

Wrapping up

With a little bit of setup we were able to make using Angular environments safer and more comfortable. We've already got typing and dependency injection at our disposal, so it's advisable to leverage these tools for a better development experience. Especially in bigger applications with multiple environments we can greatly benefit from properly defined interfaces, good test coverage and test-driven development.

Discussion

pic
Editor guide
Collapse
briancodes profile image
Brian

Using DI for testing is a really good idea! Haven't seen that before. Is useFactory necessary in the example - I would have thought that useValue would work for the same in the main app as in the tests?

Collapse
n_mehlhorn profile image
Nils Mehlhorn Author

Yeah, useValue should work as well, I'm just more used to useFactory in the actual module because I use that when providing window or localStorage.

Shouldn't make too much of a difference though with useFactory you could add some pre-processing later on.

Collapse
estylzz profile image
Eric Barb

Never considered a type/interface for environment . Great tip!