Wie schreibe ich testbaren Code? Khalils Methodik

Zu verstehen, wie man testbaren Code schreibt, ist eine der größten Frustrationen, die ich hatte, als ich die Schule beendete und anfing, an meinem ersten realen Job zu arbeiten.

Während ich heute an einem Kapitel in solidbook.io arbeitete, zerlegte ich Code und nahm alles heraus, was daran falsch war. Und mir wurde klar, dass verschiedene Prinzipien bestimmen, wie ich Code schreibe, um testbar zu sein.

In diesem Artikel möchte ich Ihnen eine einfache Methode vorstellen, die Sie sowohl auf Front-End- als auch auf Back-End-Code anwenden können, um testbaren Code zu schreiben.

Vorausgesetzte Lesungen

Vielleicht möchten Sie die folgenden Stücke vorher lesen. ?

  • Abhängigkeitsinjektion & Inversion erklärt | Node.js mit TypeScript
  • Die Abhängigkeitsregel
  • Das Prinzip der stabilen Abhängigkeit - SDP

Abhängigkeiten sind Beziehungen

Sie wissen das vielleicht schon, aber das erste, was Sie verstehen müssen, ist, dass, wenn wir den Namen einer anderen Klasse, Funktion oder Variablen aus einer Klasse importieren oder sogar erwähnen (nennen wir dies die Quellklasse ), alles, was erwähnt wurde, eine Abhängigkeit von der wird Quellklasse.

In dem Artikel zur Inversion und Injektion von Abhängigkeiten haben wir uns ein Beispiel für a angesehen UserController, das Zugriff auf a benötigt UserRepo, um alle Benutzer zu erhalten .

// controllers/userController.ts import { UserRepo } from '../repos' // Bad /** * @class UserController * @desc Responsible for handling API requests for the * /user route. **/ class UserController { private userRepo: UserRepo; constructor () { this.userRepo = new UserRepo(); // Also bad. } async handleGetUsers (req, res): Promise { const users = await this.userRepo.getUsers(); return res.status(200).json({ users }); } } 

Das Problem bei diesem Ansatz war, dass wir dabei eine harte Quellcode-Abhängigkeit erstellen .

Die Beziehung sieht wie folgt aus:

UserController verlässt sich direkt auf UserRepo.

Das bedeutet, wenn wir jemals testen wollten UserController, müssten wir auch UserRepofür die Fahrt mitbringen . Die Sache UserRepoist jedoch, dass es auch eine ganze verdammte Datenbankverbindung mit sich bringt. Und das ist nicht gut.

Wenn wir eine Datenbank hochfahren müssen, um Unit-Tests auszuführen, werden alle unsere Unit-Tests langsamer.

Letztendlich können wir dies beheben , indem wir die Abhängigkeitsinversion verwenden und eine Abstraktion zwischen den beiden Abhängigkeiten setzen.

Abstraktionen , die den Abhängigkeitsfluss invertieren können, sind entweder Schnittstellen oder abstrakte Klassen .

Verwenden einer Schnittstelle zum Implementieren der Abhängigkeitsinversion.

Dies funktioniert, indem eine Abstraktion (Schnittstelle oder abstrakte Klasse) zwischen der zu importierenden Abhängigkeit und der Quellklasse platziert wird. Die Quellklasse importiert die Abstraktion und bleibt testbar, da wir alles übergeben können, was dem Vertrag der Abstraktion entspricht, auch wenn es sich um ein Scheinobjekt handelt .

// controllers/userController.ts import { IUserRepo } from '../repos' // Good! Refering to the abstraction. /** * @class UserController * @desc Responsible for handling API requests for the * /user route. **/ class UserController { private userRepo: IUserRepo; // abstraction here constructor (userRepo: IUserRepo) { // and here this.userRepo = userRepo; } async handleGetUsers (req, res): Promise { const users = await this.userRepo.getUsers(); return res.status(200).json({ users }); } } 

In unserem Szenario mit UserControllerbezieht es sich jetzt auf eine IUserRepoSchnittstelle (die nichts kostet) und nicht auf die potenziell schwere Schnittstelle, die UserRepoüberall eine Datenbankverbindung mit sich bringt.

Wenn wir den Controller testen wollen, können wir das erfüllen UserController‚s Notwendigkeit einer IUserRepodurch Substitution unsere db-backed UserRepofür eine In-Memory - Implementierung . Wir können eine davon erstellen:

class InMemoryMockUserRepo implements IUserRepo { ... // implement methods and properties } 

Die Methodik

Hier ist mein Denkprozess, um Code testbar zu halten. Alles beginnt, wenn Sie eine Beziehung von einer Klasse zur anderen erstellen möchten.

Start: Sie möchten den Namen einer Klasse aus einer anderen Datei importieren oder erwähnen.

Frage: Ist es Ihnen wichtig, in Zukunft Tests für die Quellklasse schreiben zu können?

Wenn nein , importieren Sie alles, was es ist, da es keine Rolle spielt.

Wenn ja , beachten Sie die folgenden Einschränkungen. Sie können sich nur dann auf die Klasse verlassen, wenn es sich um mindestens eine davon handelt:

  • Die Abhängigkeit ist eine Abstraktion (Schnittstelle oder abstrakte Klasse).
  • Die Abhängigkeit stammt aus derselben Ebene oder einer inneren Ebene (siehe Die Abhängigkeitsregel).
  • Es ist eine stabile Abhängigkeit.

Wenn mindestens eine dieser Bedingungen erfüllt ist, importieren Sie die Abhängigkeit, andernfalls nicht.

Durch das Importieren der Abhängigkeit besteht die Möglichkeit, dass es in Zukunft schwierig sein wird, die Quellkomponente zu testen.

Auch hier können Sie mithilfe der Abhängigkeitsinversion Szenarien beheben, in denen die Abhängigkeit gegen eine dieser Regeln verstößt.

Frontend-Beispiel (Reagieren mit TypeScript)

Was ist mit der Front-End-Entwicklung?

Es gelten die gleichen Regeln!

Nehmen Sie diese React-Komponente (Pre-Hooks) mit einer Containerkomponente (Problem der inneren Schicht), die von einer ProfileService(äußere Schicht - unten) abhängt .

// containers/ProfileContainer.tsx import * as React from 'react' import { ProfileService } from './services'; // hard source-code dependency import { IProfileData } from './models' // stable dependency interface ProfileContainerProps {} interface ProfileContainerState { profileData: IProfileData | {}; } export class ProfileContainer extends React.Component { private profileService: ProfileService; constructor (props: ProfileContainerProps) { super(props); this.state = { profileData: {} } this.profileService = new ProfileService(); // Bad. } async componentDidMount () { try { const profileData: IProfileData = await this.profileService.getProfile(); this.setState({ ...this.state, profileData }) } catch (err) { alert("Ooops") } } render () { return ( Im a profile container ) } } 

Wenn ProfileServiceNetzwerkaufrufe an eine RESTful-API erfolgen, können wir sie nicht testen ProfileContainerund verhindern, dass sie echte API-Aufrufe ausführt.

Wir können dies beheben, indem wir zwei Dinge tun:

1. Putting an interface in between the ProfileService and ProfileContainer

First, we create the abstraction and then ensure that ProfileService implements it.

// services/index.tsx import { IProfileData } from "../models"; // Create an abstraction export interface IProfileService { getProfile: () => Promise; } // Implement the abstraction export class ProfileService implements IProfileService { async getProfile(): Promise { ... } } 

An abstraction for ProfileService in the form of an interface.

Then we update ProfileContainer to rely on the abstraction instead.

// containers/ProfileContainer.tsx import * as React from 'react' import { ProfileService, IProfileService } from './services'; // import interface import { IProfileData } from './models' interface ProfileContainerProps {} interface ProfileContainerState { profileData: IProfileData | {}; } export class ProfileContainer extends React.Component { private profileService: IProfileService; constructor (props: ProfileContainerProps) { super(props); this.state = { profileData: {} } this.profileService = new ProfileService(); // Still bad though } async componentDidMount () { try { const profileData: IProfileData = await this.profileService.getProfile(); this.setState({ ...this.state, profileData }) } catch (err) { alert("Ooops") } } render () { return ( Im a profile container ) } } 

2. Compose a ProfileContainer with a HOC that contains a valid IProfileService.

Now we can create HOCs that use whatever kind of IProfileService we wish. It could be the one that connects to an API like what follows:

// hocs/withProfileService.tsx import React from "react"; import { ProfileService } from "../services"; interface withProfileServiceProps {} function withProfileService(WrappedComponent: any) { class HOC extends React.Component { private profileService: ProfileService; constructor(props: withProfileServiceProps) { super(props); this.profileService = new ProfileService(); } render() { return (  ); } } return HOC; } export default withProfileService; 

Or it could be a mock one that uses an in-memory profile service as well.

// hocs/withMockProfileService.tsx import * as React from "react"; import { MockProfileService } from "../services"; interface withProfileServiceProps {} function withProfileService(WrappedComponent: any) { class HOC extends React.Component { private profileService: MockProfileService; constructor(props: withProfileServiceProps) { super(props); this.profileService = new MockProfileService(); } render() { return (  ); } } return HOC; } export default withProfileService; 

For our ProfileContainer to utilize the IProfileService from an HOC, it has to expect to receive an IProfileService as a prop within ProfileContainer rather than being added to the class as an attribute.

// containers/ProfileContainer.tsx import * as React from "react"; import { IProfileService } from "./services"; import { IProfileData } from "./models"; interface ProfileContainerProps { profileService: IProfileService; } interface ProfileContainerState { profileData: IProfileData | {}; } export class ProfileContainer extends React.Component { constructor(props: ProfileContainerProps) { super(props); this.state = { profileData: {} }; } async componentDidMount() { try { const profileData: IProfileData = await this.props.profileService.getProfile(); this.setState({ ...this.state, profileData }); } catch (err) { alert("Ooops"); } } render() { return Im a profile container } } 

Finally, we can compose our ProfileContainer with whichever HOC we want- the one containing the real service, or the one containing the fake service for testing.

import * as React from "react"; import { render } from "react-dom"; import withProfileService from "./hocs/withProfileService"; import withMockProfileService from "./hocs/withMockProfileService"; import { ProfileContainer } from "./containers/profileContainer"; // The real service const ProfileContainerWithService = withProfileService(ProfileContainer); // The mock service const ProfileContainerWithMockService = withMockProfileService(ProfileContainer); class App extends React.Component { public render() { return ( ); } } render(, document.getElementById("root")); 

I'm Khalil. I'm a Developer Advocate @ Apollo GraphQL. I also create courses, books, and articles for aspiring developers on Enterprise Node.js, Domain-Driven Design and writing testable, flexible JavaScript.

This was originally posted on my blog @ khalilstemmler.com and appears in Chapter 11 of solidbook.io - An Introduction to Software Design & Architecture w/ Node.js & TypeScript.

You can reach out and ask me anything on Twitter!