Резюме

Это функция, которая создаёт объект определённого типа с уже установленными валидными значениями по-умолчанию. И предоставляет возможность вручную определить необходимые для работы параметры на выбор.

Введение

Хочу рассказать про штуку, которую считаю одной из полезнейших, что я внедрил в проекты Ozon Эквайринга за последний год. Это небольшой, но очень важный шаг к приложению, которое можно просто и быстро протестировать

Проблема

Когда разработчик пишет юнит тест, часто получается так, что входной параметр тестируемой функции это объект, у которого есть данные, которые обязательно должны быть определены. Даже если они не нужны для конкретного теста.

Приходится каждый раз определять начальные значения для каждого юнита. Выглядит это примерно так:

describe("ChangeUserEmail (Изменить почту пользователя)", () => {
  const initialUserState: User = {
    firstName: "",
    lastName: "",
    email: "mr.anderson@gmail.com",
    phone: "",
  }
 
  const state = userReducer(initialUserState, new ChangeUserEmail("neo@gmail.com"))
 
  test("В бизнес стейте обновляется поле email", () => {
    expect(state.email).toBe("neo@gmail.com")
  })
})
  • В следующем тесте на изменение имени, телефона и т.п. придётся создать такой же объект, где нужно подставить только одну строку;
  • Если объект сложный и состоит из множества объектов, то воспроизвести нужно и их.

Решение

Решением этой проблемы могут быть фабрики значений по-умолчанию. Для того, чтобы начать их использовать, нужно добавить в файл к типу функцию с таким же именем, просто в camelCase:

export interface User {
  firstName: string
  lastName: string
  email: string
  phone: string
}
 
export const user = (data: Partial<User> = {}): User => ({
  firstName: "",
  lastName: "",
  email: "",
  phone: "",
  ...data,
})

Всем полям присваиваются значения по умолчанию. Через параметр функции передаётся переменная data: Partial<User> с переопределением некоторых полей, которые пользователь кода захочет изменить.

Если нужно будет добавить новое поле в тип, то с фабрикой это легко сделать, не сломав ничего в множестве тестов, где он используется. Линтер сразу же начнёт ругаться: “Добавь в фабрику дефолтное поле”.

Результат

  • Код теста упрощается
  • Уменьшается количество визуального мусора
  • При добавлении новых полей в класс, не ломаются уже написанные тесты
describe("ChangeUserEmail (Изменить почту пользователя)", () => {
  const state = userReducer(
    user({ email: "mr.anderson@gmail.com" }),
    new ChangeUserEmail("neo@gmail.com"),
  )
 
  test("В бизнес стейте обновляется поле email", () => {
    expect(state.email).toBe("neo@gmail.com")
  })
})

Типы и их дефолтные значения

  • string - ""
  • number - 0
  • boolean - false
  • Array<any> - []
  • Object какого-то типа - фабрика этого типа
  • Любое опциональное значение - undefined

Некоторые мысли

  • На один тип лучше делать один файл;
  • Тип и фабрика должны лежать в одном файле, так их легче поддерживать;
  • Фабрику лучше называть так же, как сам тип, просто с маленькой буквы. Это позволяет быстро находить нужную фабрику и не допускает конфликта имён;
  • Фабрики типа можно использовать для дефолтного стейта в сторе Redux, Vuex;
  • Если есть желание использовать фабрики повсюду в проекте, то лучше этого избегать, и стараться формировать тип так, чтобы он содержал в себе только то, что будет действительно использоваться. У моего друга Android-разработчика в проекте вообще стоит декоратор @testOnly над фабриками дефолтных значений.

Тэги

#фронтенды,#юнитТестирование

Вдохновляют

  • @shevtsov200
  • Супрематисты, но эти не то чтобы сильно

Материалы

  • Владимир Хориков: Принципы юнит тестирования - В разделе 3.3.3 он использует похожие фабрики
  • James Shore: Testing Without Mocks: A Pattern Language - Джеймс Шор называет это Parameterless Instantiation и добавляет туда уже ненулевые значения, но суть та же