Или: почему и React.useState(..), и микросервисы с отслеживанием состояния усложняют тестирование.

Однажды утром я работал над кодом внешнего интерфейса и придумал короткий способ описать то, что я неоднократно видел в отношении сложности системы. Это применимо ко всем уровням проектирования системы, как в программном обеспечении, так и вне его, но мы рассмотрим это с помощью Typescript как простого способа увидеть проблему.

Если у вас есть функция, подобная приведенной ниже, при условии, что она не имеет состояния, то протестировать ее очень просто:

function doSomething(value: boolean): number {
  return value ? 1 : 0
}

Вы можете полностью протестировать эту функцию и быть полностью уверенным, что она работает, выполнив следующие действия:

assert(doSomething(false).equals(correctAnswerForFalse));
assert(doSomething(true).equals(correctAnswerForTrue));

Легкий!

Но давайте посмотрим, что произойдет, когда мы увеличим количество параметров:

function doSomething(a: boolean, b: boolean): number {
  return (
    (a ? 1 : 0) +
    (b ? 1 : 0)
  )
}

Теперь, чтобы проверить это, нам нужно будет проверить:

┌───────┬───────┬────────┐
│   a   │   b   │ assert │
├───────┼───────┼────────┤
│ false │ false │      0 │
│ false │ true  │      1 │
│ true  │ false │      1 │
│ true  │ true  │      2 │
└───────┴───────┴────────┘

Хм. Итак, мы видим, что сложность увеличивается по мере увеличения количества параметров, а также при расширении типа каждого из параметров.

Но что произойдет, если мы добавим еще и состояние? Мы снова воспользуемся самой простой версией состояния — просто сохраним предыдущее значение одного из параметров:

let state: boolean = false
function doSomething(a: boolean, b: boolean): number {
  const result = (
    ((a || state) ? 1 : 0) +
    (b ? 1 : 0)
  )
  state = a
  return result
}

Теперь, чтобы выполнить исчерпывающее тестирование, состояние означает, что мы также должны учитывать последовательность:

┌───────────────┬───────────────┬─────────────────┬────────┐
│ 1st time a is │ 2nd time a is │ both times b is │ assert │
├───────────────┼───────────────┼─────────────────┼────────┤
│ false         │ false         │ false           │      0 │
│ false         │ true          │ false           │      1 │
│ false         │ false         │ true            │      1 │
│ true          │ false         │ false           │      1 │
│ true          │ true          │ false           │      2 │
│ true          │ false         │ true            │      2 │
│ false         │ true          │ true            │      2 │
│ true          │ true          │ true            │      3 │
└───────────────┴───────────────┴─────────────────┴────────┘

Вау! 😲 На первый взгляд кажется, что состояние действует как дополнительный параметр, увеличивая сложность в те же 2^n-1 раз 😕…но на самом деле все гораздо хуже — потому что состояние можно разделить вот так:

let state: boolean = false
function doSomething(a: boolean, b: boolean): number {
  // ...
}
function doSomethingElse(a: boolean, b: boolean): number {
  // ...
}

Теперь, чтобы протестировать наш метод, мы должны учитывать последовательность обеих функций:

First test when doSomething(..) is called first, 
and then doSomethingElse(..) afterwards
┌───────────────┬───────────────┬─────────────────┐
│ 1st time a is │ 2nd time a is │ both times b is │
├───────────────┼───────────────┼─────────────────┤
│ false         │ false         │ false           │
│ false         │ true          │ false           │
│ false         │ false         │ true            │
│ true          │ false         │ false           │
│ true          │ true          │ false           │
│ true          │ false         │ true            │
│ false         │ true          │ true            │
│ true          │ true          │ true            │
└───────────────┴───────────────┴─────────────────┘
Then also test when doSomethingElse(..) is called first, 
and then doSomething(..) afterwards
┌───────────────┬───────────────┬─────────────────┐
│ 1st time a is │ 2nd time a is │ both times b is │
├───────────────┼───────────────┼─────────────────┤
│ false         │ false         │ false           │
│ false         │ true          │ false           │
│ false         │ false         │ true            │
│ true          │ false         │ false           │
│ true          │ true          │ false           │
│ true          │ false         │ true            │
│ false         │ true          │ true            │
│ true          │ true          │ true            │
└───────────────┴───────────────┴─────────────────┘

Помните, что описанная выше сложность — это самое простое состояние — проверка двух логических параметров и одного логического состояния только двумя методами.

Но что, если мы хотим тщательно протестировать несколько сложных методов или более подробное состояние? 🤯

┌────────┬─────────┬──────────────────┬─────────────────┐
│ Params │ Total   │ Permutations     │ Permutations    │
│  per   │ methods │  if stateless    │  if stateful    │
│ method │ tested  │                  │                 │
├────────┼─────────┼──────────────────┼─────────────────┤
│      3 │       3 │              125 │ 3375            │
│      3 │       4 │              125 │ 32000           │
│      4 │       4 │              625 │ 160000          │
│      4 │       5 │              625 │ 1953125         │
│      5 │       5 │             3125 │ 145800000       │
│    ... │     ... │              ... │ ...             │
│      5 │      30 │             3125 │ 6.4340925e47 😱 │
└────────┴─────────┴──────────────────┴─────────────────┘
// ^ assuming about 30 methods per class is an average
// (and there are approx 10e17 grains of sand in the whole world)

Это один класс с состоянием. А теперь представьте, что для всей системы…

Вот картинка, которую я беззастенчиво скопировал из Блога Oracle о микросервисах:

Я позаимствовал его, потому что это хороший пример проблемы, которую мы исследовали, и поэтому оказалось, что этот дизайн на самом деле довольно плохая идея (с очень узкой точки зрения «как, черт возьми, мы можем проверить это исчерпывающе?» ).

Но как часто вы видели это в небольших проектах, например, во внешнем интерфейсе:

И эти хранилища состояний, будь то React.useState(..) или внутренние хранилища состояний микросервисов, усложняют тестирование, добавляя проблему последовательности тестов, как мы видели выше.

Итак, как нам решить эту проблему?

Что ж, собственная рекомендация React по этому поводу — хорошее руководство: по возможности избегайте состояния. Если вы написали код React, вы заметили, что все их встроенные компоненты для элементов управления HTML в значительной степени не имеют состояния — вам нужно передать значение и изменить это значение в ответ на событие onChange (или подобное):

<input
  value={firstName}
  onChange={e => setFirstName(e.target.value)}
/>

Этот шаблон, по сути, поднимает состояние до родительского элемента управления, который сам может также поднимать состояние до своего родителя и т. д. Подъем состояния позволяет этим компонентам React быть «детерминированными» компонентами без состояния — это означает, что их вывод определяется только их вводом, без окружающего предложения о мутации.

Таким образом, мы можем сделать то же самое и на уровне микросервисов: снять состояние и сделать их детерминированными службами без сохранения состояния. Затем, когда вы передаете входные данные службе, вы включаете состояние службы как часть предоставленных вами входных данных.

Как передать состояние микросервису? Для этого есть шаблон, и он на самом деле довольно прост. Я готовлю выступление на Youtube, посвященное тому, «как», которое скоро будет…!