My approach to  decoupled, testable code in React (Native)

My approach to decoupled, testable code in React (Native)

Featured on Hashnode

Building software is like a labyrinth. There are one thousand ways, but at some point only a few of them turn out to be suitable for your way. Chosing the wrong architecture for your application won't be a great issue at the beginning. But as your app grows more in size, so does the time to change. That's exactly why I want to talk about decoupled React (Native) code in this issue.

What does good architecture gets us?

For the most part:

  • shorter time to implement a new feature or fix a bug

  • testability

The result in a nutshell

We want to abstract an http client, so we can create a dummy for it and use it in our tests with ease.

Abstracting behavior

The current situation

Let's say we're already dealing with existing code at an early stage. We have an http client that has one method: request. It takes one argument requestOptions, whose type is already defined as an interface RequestOptions.

export type RequestMethod = 'post' | 'get';

export interface RequestOptions {
  requestMethod: RequestMethod; 
  data?: any;
  url: string;
  timeout?: number;
  headers?: Record<string, string | number | boolean>;
};

export class AxiosHttpClient {
  constructor(private _token: string) {}

  request<T>(requestOptions: RequestOptions): Observable<T> {
    return Axios.request(requestOptions).pipe(
      map((response) => JSON.parse(response.data) as T)
    );
  }

  authorizedRequest<T>(requestOptions: RequestOptions): Observable<T> {
     requestOptions.headers = {
      ...requestOptions.headers,
      Authorization: `Bearer ${this._token}`
     };
     return this.request<T>(requestOptions);
  }
}

1. Create an interface that suits the current implementation

export interface HttpClient {
  request<T>(requestOptions: RequestOptions): Observable<T>;
};

Note that we are not requiring the HttpClient to provide an implementation for an authorizedRequest<T>. We will take care of this in the next step.

Let's make the Axios httpClient implement our newly created interface.

export class AxiosHttpClient implements HttpClient {
  constructor() {} // we remove the token from here.
...

2. Moving out our authorizedRequest<T> method

With this in place we can even create an AuthorizedHttpClient that implements the same interface HttpClient.

This will take the token as a constructor parameter, as well as an HTTP client that we want to use the Authorization header with.

export class AuthorizedHttpClient implements HttpClient {
  constructor(private _httpClient: HttpClient, private _token: string) {}

  request<T>(requestOptions: RequestOptions): Observable<T> {
    requestOptions.headers = {
      ...requestOptions.headers,
      Authorization: `Bearer ${this._token}`
     };
     return this._httpClient.request(requestOptions);
  }
}

In theory, we could have just created an abstraction of our AxiosHttpClient called AuthorizedAxiosHttpClient, but this would lead to duplicate code in our HTTP dummy later on.

3. But how do I change to an authorized request?

This might look weird and you are surely asking yourself, will I have to decide every time, which instance to use, once the token becomes null?

No. Let us just build a factory method for that!

export function makeHttpClient(token?: string): HttpClient {
    const axiosHttpClient = new AxiosHttpClient();

    if (!!token)
        return new AuthorizedHttpClient(axiosHttpClient, token);

    return axiosHttpClient;
}

4. Using our factory method in production code

To use this in our React components, we would rely on React's Dependency Injection. This might look like the following.

export function SampleScreen({httpClient}): React.FC<{httpClient: HttpClient}> {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    httpClient.request({
      url: '/users', requestMethod: 'get'
    }).subscribe((retrievedUsers)=> setUsers(retrievedUsers));
  }, []);
  return <>
    {users.map(c => <p key={c.id}>{c.username}</p>)}
  </>;
}

export function App() {
  const [token, setToken] = useState(undefined);
  const [httpClient, setHttpClient] = useState(undefined);

  useEffect(() => {
    setHttpClient(makeHttpClient(token));
  }, [token]);

  return <div>
    <SampleScreen httpClient={httpClient}></SampleScreen>
  </div>
}

We will change our httpClient whenever the token changes. So once we set our token to a value, our httpClient changes to an AuthorizedAxiosHttpClient and vice versa.

5. Writing tests made simple

With this architecture, creating a non-authorized, as well as an authorized HttpClientDummy becomes easy.

export class HttpClientDummy<T> implements HttpClient {
  public lastRequestOptions: RequestOptions;
  constructor(private _response: T) {}

  request<T>(requestOptions: RequestOptions): Observable<T> {
    this.lastRequestOptions = requestOptions;
    return of(this._response);
  }
}

describe("SampleScreen", () => {
  it("Displays all users correctly", (done) => {
    const client = new HttpClientDummy<User[]>([{username: 'Alexander Panov'}]);
    const authorizedClient = new AuthorizedHttpClient(
      client,
      'authtoken'
    );
    const screen = render(<SampleScreen httpClient={authorizedClient}></SampleScreen>);
    client.request({requestMethod: 'get', url: '/users')
      .subscribe(() => {
        expect(screen).toContain('Alexander Panov');
        expect(
          client.lastRequestOptions.headers['Authorization']
        ).toEqual('authtoken');
        done();
      });
  });
});

Note how we only need to fake one HttpClient. Even for our AuthorizedHttpClient. We just instantiate the AuthorizedHttpClient with our HttpClientDummy.

Conclusion

Writing good code becomes more important, the bigger the code gets. Getting your dependencies right from the start will make your life easier, even if you don't write your tests now.

I would be very thrilled if you were to share this with a developer friend of yours!