sleepy

TOPICS

IntroductionReact (with MobX)ArchitectureModelsNetworkingData TransformersStoresViews & View Models

Data Transformers

Responsibility

This layer makes sets up the networking call and processes the response into simplified objects (or errors) that the app can use.

Description - Why another layer?

A Data Transformer transforms data to and from the networker. The networker should never do any logic other than making a call and returning the response. These are tied directly to the networking layer because they unpack the data, verify it, and return the resolved promise. We can also use these to format errors into something consistent the app can process. Finally, we can use these to do any sort of transformations (like trimming strings or setting up a payload) that the networker might need in order to send the call.

Weather Example

This is tied directly to our OpenWeatherNetworkable interface and implementation. On projects I've worked on, the networker and data transformer concepts are usually one class, I just separate them to make mocking and testing super easy without needing to pull in a mock networking library or do any extra setup.

Let's start with an interface of course! This will look very similar to our networker's interface, but the response type is different because we'll be transforming the response from Open Weather and translating it into what our app will use.

1 2 3 interface OpenWeatherDataTransformable { getWeatherForZip(zipCode: string): Promise<Weather>; }

Next let's set up a class that implements this.

1 2 3 4 5 class OpenWeatherDataTransformer implements OpenWeatherDataTransformable { getWeatherForZip(zipCode: string): Promise<Weather> { // TODO } }

This will need to take a networker as a dependency, so we can add that. Be sure to make sure the type is of the interface and NOT the implementation

1 2 3 4 5 6 7 8 9 class OpenWeatherDataTransformer implements OpenWeatherDataTransformable { private readonly networker: OpenWeatherNetworkable; constructor(networker: OpenWeatherNetworkable) { this.networker = networker; } getWeatherForZip(zipCode: string): Promise<Weather> { // TODO } }

The first thing we know we need to do is translate the response's data to the object our app needs, so let's set that up

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 getWeatherForZip(zipCode: string): Promise<Weather> { return this.networker .getWeatherForZip(zipCode) .then((response) => { // 1. Verify the response is in the format we expect. If not, return a rejected promise with an error explaining if (!isCurrentWeatherResponse(response)) { return Promise.reject({message: "Data is in an unexpected format"}); } // 2. Translate the Kelvin temperature into Celsius const kelvin = response.data.main.temp; const celsius = Math.floor(kelvin - 273.15); // 3. Create a Weather object const weather: Weather = { temperature: celsius, city: response.data.name, }; // 4. Resolve the promise with the weather object return Promise.resolve(weather); }) .catch(() => Promise.reject({message: "Network error"})); }

Next we need to set up our call for getting current weather by location, so I'm going to add another method to our networking interface which again matches very closely to our OpenWeatherNetworkable interface, with only the response being different.

1 2 3 4 5 interface OpenWeatherDataTransformable { getWeatherForZip(zipCode: string): Promise<Weather>; getWeatherForCoordinates(longitude: number, latitude: number): Promise<Weather>; }

If you're following along, go ahead and try to fill out the second method 😁. Some questions to keep in mind while doing so

  1. Are there any areas that can be pulled out into their own method and reused?
  2. Are there any types or objects we want to abstract?

This is what my final result looks like

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 const NetworkError = { message: "Data is in an unexpected format", }; const UnexpectedDataFormatError = { message: "Data is in an unexpected format", }; class OpenWeatherDataTransformer implements OpenWeatherDataTransformable { private readonly networker: OpenWeatherNetworkable; constructor(networker: OpenWeatherNetworkable) { this.networker = networker; } getWeatherForZip(zipCode: string): Promise<Weather> { return this.networker .getWeatherForZip(zipCode) .then((response) => this.processCurrentWeatherResponse(response)) .catch(() => Promise.reject(NetworkError)); } getWeatherForCoordinates(longitude: number, latitude: number): Promise<Weather> { return this.networker .getWeatherForCoordinates(longitude, latitude) .then((response) => this.processCurrentWeatherResponse(response)) .catch(() => Promise.reject(NetworkError)); } private processCurrentWeatherResponse(response: NetworkResponse<CurrentWeatherResponse>): Promise<Weather> { // 1. Verify the response is in the format we expect. If not, return a rejected promise with an error explaining if (!isCurrentWeatherResponse(response)) { return Promise.reject(UnexpectedDataFormatError); } // 2. Translate the Kelvin temperature into Celsius const kelvin = response.data.main.temp; const celsius = Math.floor(kelvin - 273.15); // 3. Create a Weather object const weather: Weather = { temperature: celsius, city: response.data.name, }; // 4. Resolve the promise with the weather object return Promise.resolve(weather); } }

Tests

The best part, and the reason for our abstractions!

When we separated our networking layer via an interface (OpenWeatherNetworkable), we set ourselves up to be able to write tests on this class super easily. One of the biggest keys for easily writing testable code is injection. If we called our networking layer directly in this class, it would be more difficult to write tests. We may need an extra library to intercept our calls to Open Weather and handle them separately. This can be dangerous (especially in more complicated apps) because there's the possibility that we start actually hitting the network when we don't want to. This could cause problems like tests failing when the networking is down, not having complete control over our tests responses, and tests taking longer than they need to because they're going out to a server and back, instead of just generating local responses.

Where you set up your tests is up to you (see the "Extras" section for info on Project Structure). For these examples, my tests will live in the same directory as the unit that it's testing, and we'll name them using .test.ts as the suffix.

So let's create a file called OpenWeatherDataTransformer.test.ts.

Inside, we can start the setup like this

1 2 3 4 5 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; // TODO: Write tests });

Because we want every test to run in isolation, we'll want to do a set up method.

1 2 3 4 5 6 7 8 9 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; beforeEach(() => { dataTransformer = new OpenWeatherDataTransformer(); }); // TODO: Write tests });

This code will have an error because there isn't a networker injected. We need to set up our first mock, which will be a mock OpenWeatherNetworkable networker!

I keep all of my mocks in the same directory, in src/__tests__/mocks so that it's very clear these implementations are just for testing, development, and things like Storybook.

This mock will implement OpenWeatherNetworkable. For any mock classes I create, I always do the least amount of effort to set up the mock and any functions return the happy path (we can override them in our test files for individual tests).

1 2 3 4 5 6 7 8 9 10 11 class MockOpenWeatherNetworker implements OpenWeatherNetworkable { getWeatherForCoordinates(params: { lon: number; lat: number }): NetworkPromise<CurrentWeatherResponse> { return Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Mock City Name" } }); } getWeatherForZip(params: { zip: string }): NetworkPromise<CurrentWeatherResponse> { return Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Mock City Name" } }); } } export default MockOpenWeatherNetworker;

So what exactly is this? This mock's functions are returning an exact replica of a successful response from the Open Weather API. This data is not made up, but is based on Open Weather's documentation and responses I have gotten myself (with some data changed so it's obvious that it's mock data). Now we have our own local mocked out networking layer that we can use in our data transformer for tests! Back to that

1 2 3 4 5 6 7 8 9 10 11 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); // TODO: Write tests });

Our test file is properly set up now to write some tests!

What Do I Test?

There are so many fantastic resources out there for writing good tests, so I won't go into too much detail here. The way I think about writing unit tests is a black box. I want to be able to put some data in, and verify what comes out, and it doesn't matter how it gets there as long as the output is what we expect for a specific input.

For something like this, we definitely want to test happy path scenarios, but we also want to make sure our networker is error handling appropriately as well, whether the data is in an unexpected format, the server responds with a 400, etc. We also want to make sure the data transformer is unwrapping the network response and formatting it in the way the rest of the app expects too.

So let's start with an easy happy path, and let's focus on getWeatherForZip to start. I tend to use the GIVEN/WHEN/THEN format to write tests so it's human readable, and then sometimes I write out some of the tests I know I need to do before filling them in. Here's an example

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is a successful", () => { // TODO: Happy path }); describe("when the response is unsuccessful", () => { // TODO: Sad path }); }); });

Say the response is successful with valid data, and we just want to make sure the response from the data transformer was created successfully. Here's how we might start that

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is a successful", () => { let response: unknown; beforeEach(async () => { response = await dataTransformer.getWeatherForZip("55555"); }); it("should return format the response into a Weather object", () => { expect(response).toEqual({ city: "Mock City Name", temperature: 21, }); }); }); describe("when the response is unsuccessful", () => { // TODO: Sad path }); }); });

In the set up for our test, we grab the response to our getWeatherForZip method using the MockOpenWeatherNetworker. We have this mock set up to automatically return a happy path, and so we verify that the response is the expected response of that happy path. These tests succeed.

Next we may want to verify that the networker was called with the proper parameters. This may seem like a simple test (and it is) but we want to make sure our networker is receiving the data in the way we expect. In order to verify our networker is called with the proper configuration, we'll want to override it with a jest method.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is a successful", () => { let response: unknown; beforeEach(async () => { // Overriding with a Jest method mockNetworker.getWeatherForZip = jest.fn(() => Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Mock City Name" } }) ); response = await dataTransformer.getWeatherForZip("55555"); }); it("should call the API with the correct parameters", () => { expect(mockNetworker.getWeatherForZip).toBeCalledWith({ zip: "55555" }); }); it("should return format the response into a Weather object", () => { expect(response).toEqual({ city: "Test City Name", temperature: 21, }); }); }); describe("when the response is unsuccessful", () => { // TODO: Sad path }); }); });

This test is useful because we can verify the parameter is called zip and not zipCode or postalCode, and we can verify the zip is in fact passed to it and not an empty string or a hard coded string (something that might happen if we are debugging and playing around with different data). A test like this may seem silly and a waste of time, but it only took a few seconds to write and now this functionality is completely locked into our app and we will be alerted if it changes.

Now let's move onto an unhappy path, but not quite the one we'd expect. What if the API responds with data in a format we don't expect? Perhaps they change their structure, or their types? We've already written the code in the OpenWeatherDataTransformer to test for this, so let's make sure it works!

I'm going to pull our current tests into another nested described which has the description "and the data is valid" so that I can create a sibling describe that says "and the ata is not valid"

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is a successful", () => { let response: unknown; describe("and the data is valid", () => { beforeEach(async () => { mockNetworker.getWeatherForZip = jest.fn(() => Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Test City Name" } }) ); response = await dataTransformer.getWeatherForZip("55555"); }); it("should call the API with the correct parameters", () => { expect(mockNetworker.getWeatherForZip).toBeCalledWith({ zip: "55555" }); }); it("should return format the response into a Weather object", () => { expect(response).toEqual({ city: "Test City Name", temperature: 21, }); }); }); describe("and the response data is not valid", () => { // TODO: Verify what happens when the data is in an invalid format }); }); }); });

And now let's set up that tests and see what it does!

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is a successful", () => { let response: unknown; describe("and the data is valid", () => { // ...previous tests that we wrote, eliminating here to shorten the code block }); describe("and the response data is not valid", () => { beforeEach(async () => { // 1. Set up a mock network failure // @ts-expect-error: We are purposely setting a mismatched type mockNetworker.getWeatherForZip = jest.fn(() => Promise.resolve({ status: 200, data: { main: {}, name: "Test City Name" } }) ); // 2. Call the method that we know will respodn in an error, catch the error, and save it to the response object await dataTransformer.getWeatherForZip("55555").catch((error) => (response = error)); }); it("should return an unexpected data error", () => { // 3. Cast the error (this is fine because if it's not a LocalError, it won't have a code property and the test will fail anyway) const error = response as LocalError; // 4. Verify the code is as we expect expect(error.code).toEqual(LocalErrorCode.UnexpectedDataFormat); }); }); }); }); });

So let's break down what we did.

  1. Set up the mock failure by keeping the 200 status and returning empty data to simulate a successful response but data in the incorrect format. This can look like anything, as long as it's not an actual Open Weathr Response
  2. Called the method we knew would create an error, and saved the error as a response so we can verify it in our tests.
  3. Cast the error as a LocalError since that's what it should be. It should have a code property, and if it doesn't, the test will fail anyway, which we'd want to see.
  4. Verify the error's code is the code we expect. I test just the code because that's more important than the message as the message may change. Feel free to test the whole error object if you want!

Now we know our data transformer properly handles data in the incorrect format! Yay!.

Now let's write some tests on if the network promise is rejected because of a server error.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { describe("when the response is successful", () => { describe("and the data is valid", () => { // ...Tests we wrote earlier, eliminating to simplify code block }); describe("and the response data is not valid", () => { // ...Tests we just wrote, eliminating to simplify code block }); }); describe("when the call fails", () => { beforeEach(() => { // 1. Mock out a rejected promise from our network layer with a 400 and no data mockNetworker.getWeatherForZip = jest.fn(() => Promise.reject({ status: 400, data: {} })); }); it("should return a network error", () => { // 2. Verify expected result expect(dataTransformer.getWeatherForZip("55555")).rejects.toEqual(NetworkError); }); }); }); });

Let's cover what we did

  1. Similar to the invalid data response, we overrode the mock networker's zip code method but this time we rejected the promise with a 400. This is what axios would do, so this is what we're mocking
  2. You'll notice here that we didn't set a response property to the error. This is another way to write tests (using the rejects.toEqual()) which can be a simpler way to check a result. It can be useful if you are only testing the response and no other side effects. In this case, I wrote it differently just to show the different ways you can do it.

Go ahead and fill in the tests for getWeatherForCoordinates! It should be similar since the responses are handled the same way. You could even potentially set up multiple cases and run the same test code (but we'll skip that for now since we only have two methods).

Here's my final test file:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 describe("OpenWeatherDataTransformer tests", () => { let dataTransformer: OpenWeatherDataTransformer; let mockNetworker: MockOpenWeatherNetworker; beforeEach(() => { mockNetworker = new MockOpenWeatherNetworker(); dataTransformer = new OpenWeatherDataTransformer(mockNetworker); }); describe("Given we are getting weather for a zip code", () => { let response: unknown; describe("when the response is successful", () => { describe("and the data is valid", () => { beforeEach(async () => { mockNetworker.getWeatherForZip = jest.fn(() => Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Test City Name" } }) ); response = await dataTransformer.getWeatherForZip("55555"); }); it("should call the API with the correct parameters", () => { expect(mockNetworker.getWeatherForZip).toBeCalledWith({ zip: "55555" }); }); it("should return format the response into a Weather object", () => { expect(response).toEqual({ city: "Test City Name", temperature: 21, }); }); }); describe("and the response data is not valid", () => { beforeEach(async () => { // @ts-expect-error: We are purposely setting a mismatched type mockNetworker.getWeatherForZip = jest.fn(() => Promise.resolve({ status: 200, data: { main: {}, name: "Test City Name" } }) ); dataTransformer.getWeatherForZip("55555").catch((error) => (response = error)); }); it("should return an unexpected data error", () => { const error = response as LocalError; expect(error.code).toEqual(LocalErrorCode.UnexpectedDataFormat); }); }); }); describe("when the call fails", () => { beforeEach(() => { mockNetworker.getWeatherForZip = jest.fn(() => Promise.reject({ status: 400, data: {} })); }); it("should return a network error", () => { expect(dataTransformer.getWeatherForZip("55555")).rejects.toEqual(NetworkError); }); }); }); describe("Given we are getting the weather for a location", () => { let response: unknown; describe("and the response is successful", () => { describe("when the data is valid", () => { beforeEach(async () => { mockNetworker.getWeatherForCoordinates = jest.fn(() => Promise.resolve({ status: 200, data: { main: { temp: 294 }, name: "Test City Name" } }) ); response = await dataTransformer.getWeatherForCoordinates(123, 456); }); it("should call the networker with the payload", () => { expect(mockNetworker.getWeatherForCoordinates).toBeCalledWith({ lat: 456, lon: 123 }); }); it("return the formatted weather", () => { expect(response).toEqual({ city: "Test City Name", temperature: 21, }); }); }); describe("and the response data is not valid", () => { beforeEach(async () => { // @ts-expect-error: We are purposely setting a mismatched type mockNetworker.getWeatherForCoordinates = jest.fn(() => Promise.resolve({ status: 200, data: { main: {}, name: "Test City Name" } }) ); dataTransformer.getWeatherForCoordinates(123, 456).catch((error) => (response = error)); }); it("should return an unexpected data error", () => { const error = response as LocalError; expect(error.code).toEqual(LocalErrorCode.UnexpectedDataFormat); }); }); }); describe("when the call fails", () => { beforeEach(() => { mockNetworker.getWeatherForCoordinates = jest.fn(() => Promise.reject({ status: 400, data: {} })); }); it("should return a network error", () => { expect(dataTransformer.getWeatherForCoordinates(123, 456)).rejects.toEqual(NetworkError); }); }); }); });

And we now have complete test coverage on this class! Feels good man 😁

What's Next?

For now we are returning a generic "Network error" message if the call fails. Eventually we'll do some better network handling, but right now we'll move onto the stores!