Как написать тестируемый код | Методология Халила
Понимание того, как писать тестируемый код, является одним из самых больших разочарований, которые у меня были, когда я закончил школу и начал работать на своей первой реальной работе.
Сегодня, когда я работал в solidbook.io, я разбивал некоторый код и разбирал все, что с ним не так. И я понял, что несколько принципов определяют, как я пишу код, чтобы быть тестируемым.
В этой статье я хочу представить вам простую методологию, которую вы можете применять как к frontend, так и к backend для написания тестируемого кода.
Предварительные показания
Вы можете прочитать следующие части заранее. 😇
- Инъекция и Инверсия Зависимости | Node.js с TypeScript
- Правило зависимости
- Принцип стабильной зависимости - SDP
Зависимости - это отношения
Вы, возможно, уже знаете это, но первое, что нужно понять, это то, что когда мы импортируем или даже упоминаем имя другого класса, функции или переменной из одного класса (назовем это исходным классом), все, что было упомянуто, становится зависимостью для исходного класса.
В статье «Инверсия и внедрение зависимостей» мы рассмотрели пример того, что UserController
необходим доступ к UserRepo
чтобы получить доступ ко всем пользователям.
// 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 });
}
}
Проблема с этим подходом состояла в том, что, когда мы делаем это, мы создаем жесткую зависимость исходного кода.
Отношения выглядят следующим образом:
UserController опирается непосредственно на UserRepo.
Это означает, что, если мы когда-нибудь захотим проверить UserController
, нам нужно будет взять UserRepo
с собой в поездку. Дело UserRepo
, однако, в том, что он также приносит с собой и чертову связь с базой данных. И это не хорошо.
Если нам нужно ускорить базу данных для запуска модульных тестов, это замедляет все наши модульные тесты.
В конечном счете, мы можем исправить это, используя инверсию зависимостей , помещая абстракцию между двумя зависимостями.
Абстракции, которые могут инвертировать поток зависимостей, являются либо интерфейсами, либо абстрактными классами.
Использование интерфейса для реализации инверсии зависимости.
Это работает, помещая абстракцию (интерфейс или абстрактный класс) между зависимостью, которую вы хотите импортировать, и исходным классом. Исходный класс импортирует абстракцию и остается тестируемым, потому что мы можем передать все, что придерживается контракта абстракции, даже если это фиктивный объект .
// 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 });
}
}
В нашем сценарии UserController
теперь относится к интерфейсу IUserRepo
(который ничего не стоит), а не к потенциально тяжелому, UserRepo
который переносит соединение с БД везде, где бы он ни находился.
Если мы хотим протестировать контроллер, мы можем удовлетворить потребность UserController
в IUserRepo
, заменив нашу db-backed UserRepo
на реализацию в памяти. Мы можем создать класс такой как этот:
class InMemoryMockUserRepo implements IUserRepo {
... // implement methods and properties
}
Методология
Вот мой мыслительный процесс для сохранения кода тестируемым. Все начинается, когда вы хотите создать отношения из одного класса в другой.
Начало: Вы хотите импортировать или упомянуть имя класса из другого файла.
Вопрос: вас волнует возможность написать тесты для исходного класса в будущем?
Если нет, продолжайте и импортируйте все, что есть, потому что это не имеет значения.
Если да, учтите следующие ограничения. Вы можете зависеть от класса, только если он хотя бы один из них:
- Зависимость - это абстракция (интерфейс или абстрактный класс).
- Зависимость от того же самого слоя или внутреннего слоя (см . Правило зависимости).
- Это стабильная зависимость.
Если хотя бы одно из этих условий выполнено, импортируйте зависимость, иначе - нет.
Импорт зависимости представляет возможность того, что в будущем будет сложно протестировать исходный компонент.
Опять же, вы можете исправить сценарии, в которых зависимость нарушает одно из этих правил, с помощью инверсии зависимости.
Пример внешнего интерфейса (React / TypeScript)
Как насчет фронтэнда?
Применяются те же правила!
Возьмите этот компонент React (pre-hooks), включающий компонент контейнера (проблема внутреннего слоя), который зависит от ProfileService
(внешний слой - ниже).
// 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<
ProfileContainerProps,
ProfileContainerState
> {
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
)
}
}
Если ProfileService
делает сетевые вызовы к RESTful API, у нас нет возможности протестировать ProfileContainer
и предотвратить реальные вызовы API.
Мы можем исправить это, выполнив две вещи:
1. Ввод в интерфейс между ProfileService и ProfileContainer
Сначала мы создаем абстракцию, а затем обеспечиваем ее реализацию в ProfileService
.
// 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 {
...
}
}
Абстракция для ProfileService в форме интерфейса.
Затем мы обновляемся, ProfileContainer
чтобы полагаться на абстракцию.
// 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<
ProfileContainerProps,
ProfileContainerState
> {
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. Составьте ProfileContainer с HOC, который содержит действительный IProfileService.
Теперь мы можем создавать HOC, которые используют все, что пожелает IProfileService
. Это может быть тот, который подключается к API следующим образом:
// 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;
Или это может быть фиктивный, который также использует службу профилей в памяти.
// 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;
Для того чтобы мы могли использовать IProfileService
объект из ProfileContainer
HOC, он должен ожидать получить IProfileService
в качестве зависимости ProfileContainer
, а не быть добавленным в класс в качестве атрибута.
// 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<
ProfileContainerProps,
ProfileContainerState
> {
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
}
}
Наконец, мы можем составить наш ProfileContainer
с любым HOC, который мы хотим - тот, который содержит реальный сервис, или тот, который содержит поддельный сервис для тестирования.
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<{}, IState> {
public render() {
return (
);
}
}
render( , document.getElementById("root"));