I tried something "new" this afternoon. I built an Angular service in a true TDD fashion. I wrote the tests first, discovering the service interface along the way. This is how it went. I invite you to follow along.
Background
I am not a fan of writing unit tests for Angular apps. The tooling I am using (Jasmine and Karma) feel like afterthoughts. They work and they have gotten much better over the past few years, but they still seem like they were written to bolt onto Angular, rather than being built as part of the ecosystem.
Then I started thinking that maybe the problem is with me. Maybe I despise writing tests because I have not truly adopted test-driven-development in my Angular apps. I used to use TDD all the time with .NET and C#.
So today I decided to go back to that philosophy and build a modest service using strict TDD principles. This is how it went.
The Service
The service itself is simple enough. I want to build a means of setting and retrieving two different unique IDs my app can use when making service calls. The first is a "conversation ID" that will be set as an HTTP header for all network calls for a specific user for a given session. It will not change until the application user manually refreshes the screen, closes the browser, or logs out and back in.
The second is the "correlation ID." This will also get sent with each HTTP call, but it changes with every request.
Not only will these IDs be set as custom HTTP headers on all web requests, they will be logged with all such requests and responses. They can then be used to correlate several layers of service requests and responses back to the user and high-level function that initiated them.
The name of my service is simply correlation
. I created it with this Angular CLI command:
npx ng g service services/correlation/Correlation
CREATE src/app/services/correlation/correlation.service.spec.ts (382 bytes)
CREATE src/app/services/correlation/correlation.service.ts (140 bytes)
This creates two files in their own folder at ./src/app/services/correlation
. I got a nearly-empty service file and a test (spec) file with one test.
As I usually do, pre-pending npx
causes the system to use the locally-installed Angular CLI.
The Generated Test
I want to start by reviewing the test code that was generated by the Angular CLI. I do not mean for this to be a comprehensive introduction to testing, but I will explain the basics. It should be enough for you to follow along and also modify your own tests.
import { TestBed } from '@angular/core/testing';
import { CorrelationService } from './correlation.service';
describe('CorrelationService', () => {
let service: CorrelationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CorrelationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
The first import
line brings in the Angular testing class called TestBed
. This class contains most of the basic testing framework.
The second pulls in the service to be tested, also known as the "System Under Test," or SUT.
describe
With most JavaScript testing frameworks, tests are organized into one or more describe
functions. These can be nested, as you will see shortly.
The describe
function is called at least two parameters.
- The test label. In this case, the name of the service to be tested.
- The function that contains the tests themselves. Here it is an arrow function.
This function contains a single variable representing the service, but nothing is assigned to it yet.
beforeEach
Directly inside this function is another function call, beforeEach
, which itself contains another arrow function. This function is called by the testing framework before every unit test.
This one calls the TestBed.configureTestingModule({})
, and you can see that it is being passed an empty object as its only argument. This is the options, and can accept just about everything a normal Angular module can. Most tests use this to configure Angular's dependency injection system to inject test doubles required by the SUT. My service has no dependencies, so there is nothing to configure.
Other Functions
Not shown are some other functions that can contain setup/tear-down instructions:
- beforeAll: called once before any tests are run.
- afterAll: called once after all tests have been run.
- afterEach: called after each unit test function.
it
This function defines a single unit test. You can create as many it
functions as you want inside your describe
. The generated test comes with a single it
function. Its signature matches that of describe
, in that it takes a label and a function defining the test.
When combined with its enclosing describe
, the it
functions should read like this:
[describe Label] [it Label]: Pass/Fail
Thus, when you read the one generated test, it should look like this:
CorrelationService should be created: Pass
Consider this phrasing when you create your own tests.
There is a lot more to Angular testing than this, but I wanted to make sure I explained what you would be seeing below before I begun.
The Tests and API
There are three primary things I need the service to do for me.
- Give me the same conversation ID whenever I ask, unless one does not exist. In that case, it needs to give me a new one and return it.
- Give me a fresh correlation ID every time I request one. I should never get the same ID twice.
- Provide a way for me to force a fresh conversation ID.
These rules allowed me to come up with the following tests. Again, I am using Jasmine as my testing framework. I know a lot of people these days are using Jest, but the concepts should be the same regardless of what you use.
import { TestBed } from '@angular/core/testing';
import { CorrelationService } from './correlation.service';
describe('CorrelationService', () => {
let service: CorrelationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CorrelationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('resetConversationId', () => {
it('should return different values on subsequent calls', () => {
const firstId = service.resetConversationId();
const secondId = service.resetConversationId();
expect(firstId).not.toEqual(secondId);
});
});
describe('getConversationId', () => {
it('should return identical values on subsequent calls', () => {
service.resetConversationId();
const firstId = service.getConversationId();
const secondId = service.getConversationId();
expect(firstId).toEqual(secondId);
});
});
describe('getCorrelationId', () => {
it('should return different values on subsequent calls', () => {
const firstId = service.getCorrelationId();
const secondId = service.getCorrelationId();
expect(firstId).not.toEqual(secondId);
});
});
});
Even if you are not intimately familiar with Angular testing in Jasmine, I think these tests are easily understood.
Naturally, though, none of these tests will run. In fact, they will not even compile. The functions on the service do not yet exist.
Auto-generated Service Code
Fortunately, VS Code will do the heavy lifting for me. All I have to do is put my edit cursor on one of the function names, click the yellow light-bulb (for Auto Fix), and choose Add all missing members.
The code it builds is not ideal and will still require some editing, but at this point the tests will compile.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CorrelationService {
resetConversationId() {
throw new Error('Method not implemented.');
}
getConversationId() {
throw new Error('Method not implemented.');
}
getCorrelationId() {
throw new Error('Method not implemented.');
}
constructor() { }
}
Make Them Run (and Fail)
Now I have code that compiles, implemented in such a way that all three tests will fail with an expected exception. The first thing I need to do is remove the exceptions. My class now looks like this.
export class CorrelationService {
resetConversationId() {
}
getConversationId() {
}
getCorrelationId() {
}
constructor() { }
}
I am afraid one of those tests will now pass, but should not. Each function call in the test code evaluates to undefined
. This causes the test should return identical values on subsequent calls
to pass, because undefined
equals undefined
.
I will have to edit the tests. I have two choices. I can add three more tests to ensure that no function returns undefined
or I can add a check for undefined
in the test that is checking for equality.
Some purists believe that every test should have a single assertion/expectation. I tend to be more of a pragmatist. If you are testing one high level "thing," then it is fine to have multiple expectations in a single test.
The new test now looks like this, and fails as expected.
describe('getConversationId', () => {
it('should return identical values on subsequent calls', () => {
service.resetConversationId();
const firstId = service.getConversationId();
const secondId = service.getConversationId();
expect(firstId).toBeDefined(); // New code
expect(firstId).toEqual(secondId);
});
});
Note I am only checking on the first result to be defined. If the first call is defined and the second is not, the second expectation will then fail. I will let you decide which approach makes sense for your project.
Make Them Pass
According to TDD principles, the next step is to write the least amount of code that will cause the tests to pass. In theory, I should not have to touch the tests again. In practice, I probably will. This is a path of discovery, which I am writing as I go. Thus, you are learning right along with me.
resetConversationId() {
return 'mike';
}
getConversationId() {
return 'mike';
}
getCorrelationId() {
return 'mike';
}
Technically, this will make the middle test pass, but not the others. It is time to think about how the service is supposed to work.
UUID
The business rules call for some sort of semi-unique identifier string. I plan to use a GUID or some variant thereof.
After a few seconds (ok, a minute or so) of research, I found the UUID npm package{:target="_blank"}. I will it use to generate both my conversation and correlation IDs.
Once the package is installed in my project, the CorrelationService now looks like this.
import { Injectable } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
@Injectable({
providedIn: 'root'
})
export class CorrelationService {
resetConversationId() {
return uuidv4();
}
getConversationId() {
return uuidv4();
}
getCorrelationId() {
return uuidv4();
}
constructor() { }
}
Now the tests pass or fail as expected.
Make It Right
This code looks pretty good, almost complete. There are two things I think are missing.
The first is obvious: Subsequent calls to getConversationId
need to return the same value. This means I need a place to store the value. There is also the scenario of the ID's initial value. How do we handle that?
I will tackle the second scenario first by modifying getConversationId
to return the stored value, and also by modifying resetConversationId
to set the stored value. This will cause the tests to fail, but that is why we write them in the first place. Right?
My modified service looks like this:
export class CorrelationService {
conversationId: string;
resetConversationId() {
this.conversationId = uuidv4();
return this.conversationId;
}
getConversationId() {
return this.conversationId;
}
getCorrelationId() {
return uuidv4();
}
constructor() { }
}
All the tests pass, because I had the foresight to call resetConversationId
in the test expecting equality. In reality, this was not a good idea. My motive was good, but I do not believe a user should be forced to call resetConversationId
before calling getConversationId
. That should be up to the code.
So, now I want to remove the call to resetConversationId
from the test, which will cause that test to fail.
To enable that code to pass again, I need to modify the service to ensure there is a value before returning it.
getConversationId() {
return this.conversationId || this.resetConversationId();
}
Now all my tests pass, the service does the modest job it is meant to do, and my test coverage looks good.
The Final Test
Here is the final set of tests.
import { TestBed } from '@angular/core/testing';
import { CorrelationService } from './correlation.service';
fdescribe('CorrelationService', () => {
let service: CorrelationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CorrelationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('resetConversationId', () => {
it('should return different values on subsequent calls', () => {
const firstId = service.resetConversationId();
const secondId = service.resetConversationId();
expect(firstId).not.toEqual(secondId);
});
});
describe('getConversationId', () => {
it('should return identical values on subsequent calls', () => {
const firstId = service.getConversationId();
const secondId = service.getConversationId();
expect(firstId).toBeDefined();
expect(firstId).toEqual(secondId);
});
});
describe('getCorrelationId', () => {
it('should return different values on subsequent calls', () => {
const firstId = service.getCorrelationId();
const secondId = service.getCorrelationId();
expect(firstId).not.toEqual(secondId);
});
});
});
The Final Service
Here is the entire service.
import { Injectable } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
@Injectable({
providedIn: 'root'
})
export class CorrelationService {
conversationId: string;
resetConversationId() {
this.conversationId = uuidv4();
return this.conversationId;
}
getConversationId() {
return this.conversationId || this.resetConversationId();
}
getCorrelationId() {
return uuidv4();
}
constructor() { }
}
I probably could also dispense with the empty constructor, but something in the back of my mind is preventing me from deleting it.
Refactoring the Service
After I finished writing this, it occurred to me that there is a better way to initialize the service than with the ||
in getConversationId
. Why not use the constructor to do its job and construct the object and initialize its internal state?
Before
As you may recall (or just look up and see), the getConversationId
function looks like this:
getConversationId() {
return this.conversationId || this.resetConversationId();
}
If the value of this.conversationId
is not defined, the conditional "or" will cause the function on the right side to be executed. That function's side-effect is to initialize the value. TypeScript conditional "short-circuiting" prevents it from being called if this.conversationId
already contains a value.
In this case, it is simple enough to follow, but you may be able imagine that in more complex classes it may not be.
After
Instead, I will move the call to resetConversationId
into the constructor, guaranteeing that this.conversationId
will always have a value. Thus, I can delete the conditional check from the latter function.
constructor() {
this.resetConversationId();
}
getConversationId() {
return this.conversationId;
}
To me, this is much simpler code and captures the meaning more clearly than before. Anyone looking at this code will understand that the service pre-initializes its state immediately.
The tests still pass, as they should. This ostensibly is why we write unit tests in the first place, to ensure that changes to the implementation do not break functionality.
Conclusion
From start to finish, this experiment took me just over two hours to complete (2:30 - 4:45 PM). I spent another 15 minutes or so doing the above refactoring and writing about it.
The tests were easy to write because the service itself did not exist when I began. By describing the tests as I expected them to work, the service API practically wrote itself.
I am not convinced that a more complicated service or a UI component will be as easy to write in this manner, but over all I am pleased with the result.
I will probably continue to develop the project this way, and can honestly recommend that everyone should give it a try some time. You may end being pleasantly surprised.
Top comments (0)