Angular Environment Setup - Safe & Testable
November 21, 2019 (updated August 13, 2020)
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 and the Angular CLI already provide 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
Any non-default environment is suffixed correspondingly, for example with ‘prod’ for your production environment. Here we also configure a staging environment which you might use for QA or testing deployments. Sometimes you’ll also have a specific environment for continuous integration (CI).
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'
}
// environment.stage.ts
export const environment = {
production: false,
name: 'stage',
apiPath: '/stage/api'
}
// environment.prod.ts
export const environment = {
production: true,
name: 'prod',
apiPath: '/prod/api'
}
Here the path under which you can reach the backend server also differs between environments - indicated by the apiPath
property. However, when properties are re-used in many placed but don’t change with the environment you may want to introduce a single separate constant.ts
file.
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"
}],
...
}
}
}
...
}
When building, you’ll activate a configuration - thus an environment - by passing it’s name to the Angular CLI:
ng build --configuration <config>
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)
But we can do better. There’s two problems I encountered with this setup:
- When adding new properties to
environment.ts
it’s easy to forget adding counterparts in the other environment files - 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
}
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'
}
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.
Got stuck? Post a comment below or ping me on Twitter @n_mehlhorn
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.
Join my mailing list and follow me on Twitter @n_mehlhorn for more in-depth knowledge on web development.
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;
}
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 { }
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 {
...
}
}
}
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}
]
});
});
});
});
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.