Тестування вбудованих застосунків на Rust: від юніт-тестів до Hardware-in-the-Loop

1. Ціна дефекту

Дефект у фінальній версії вбудованого продукту коштує дорого. На відміну від вебзастосунків, де нову версію можна розгорнути за лічені хвилини, пристрої, що вже працюють «в полі», оновити важко та витратно, а іноді й взагалі неможливо. Саме це описує крива Боема: вартість усунення дефекту швидко зростає на кожному етапі життєвого циклу продукту. Чим раніше виявлено помилку, тим дешевше її виправлення. Тому перенесення виявлення і виправлення дефектів на ранні стадії це одна з найкращих інвестицій у проєктах, що займаються вбудованими системами.

Крива Боема: вартість виправлення дефекту в залежності від стадії життєвого циклу

Саме тут зростання популярності Rust у сфері вбудованих систем стає особливо важливим. З точки зору якості, Rust має дві важливі переваги:

  • Розробка вбудованих систем пов’язана з прямою роботою з пам’яттю, де легко припуститися помилок. Модель володіння Rust повністю усуває цей клас багів ще на етапі компіляції.
  • Cистема типів Rust кодує апаратні обмеження безпосередньо в коді. Некоректні конфігурації стають помилками компіляції, а не збоями під час роботи. Наведений нижче приклад чітко це ілюструє.

Для наочності: у мікроконтролері STM32F446RE лінію тактування I2C1 можна підключити лише до виводів PB6 або PB8 (даташит, с. 59). Якщо ви випадково налаштуєте вивід SCL на 15 пін, HAL на C не видасть жодних попереджень — IDE та компілятор не мають можливості це відстежити. Тому виявити баг доведеться вже під час роботи пристрою вам або вашим користувачам.

void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(hi2c->Instance==I2C1)
  {
    __HAL_RCC_GPIOB_CLK_ENABLE();
    GPIO_InitStruct.Pin = GPIO_PIN_15|GPIO_PIN_7;
    // це помилка --------^^^^^^^^^^^
    // має бути GPIO_PIN_6
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
    __HAL_RCC_I2C1_CLK_ENABLE();
  }
}

Але спробуймо зробити те саме на Rust із неправильним виводом.

let i2c1_scl = gpiob.pb15.into_alternate_open_drain::<4>();
// помилка ----------^^^^
// має бути `pb6`

Компілятор виявить це негайно — жодне налагодження під час виконання не знадобиться.

error[E0277]: the trait bound `stm32f4xx_hal::gpio::Pin<'B', 15>: gpio::marker::IntoAf<4>` is not satisfied
  --> stm32-target/src/main.rs:34:31
   |
34 |     let i2c1_scl = gpiob.pb15.into_alternate_open_drain::<4>();
   |                               ^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `gpio::marker::IntoAf<4>` is not implemented for `stm32f4xx_hal::gpio::Pin<'B', 15>`
   |

Ба більше, компілятор сам вказує на доступні варіанти: виводи 6 та 8.

help: the following other types implement trait `From<T>`

stm32f4xx_hal::gpio::alt::i2c1::Scl implements From<stm32f4xx_hal::gpio::Pin<'B', 6, MODE>>
stm32f4xx_hal::gpio::alt::i2c1::Scl implements From<stm32f4xx_hal::gpio::Pin<'B', 8, MODE>>

Система типів Rust переносить помилки неправильної апаратної конфігурації на етап компіляції — тобто виявляє баги раніше, коли їх виправлення коштує найменше. Проте вона не може вберегти від помилок в бізнес-логіці. Для цього нам потрібні тести: юніт-тести для ізольованих компонентів, інтеграційні тести для перевірки взаємодії між ними та апаратні (Hardware-in-the-Loop) тести для перевірки на реальному залізі. Саме це ми й розглянемо у статті.

2. Структурування коду вбудованих систем для забезпечення тестованості

Перш ніж налаштовувати проєкт та писати код, необхідно обговорити важливий принцип проєктування, який допоможе створити придатний для тестування застосунок. Це принцип розділення відповідальності (Separation of Concerns, SoC) — який вимагає, щоб програмна система була розділена на окремі секції, де кожна вирішує власну ізольовану задачу (зону відповідальності). Хоча розділення відповідальності є фундаментальним принципом проєктування, який впливає на більшість аспектів розробки програмного забезпечення (напр. портабельність), тут ми розглядаємо його переважно в контексті тестування.

Типовий вбудований застосунок можна розділити на такі шари.

Архітектурна діаграма

На цій досить спрощеній діаграмі ми маємо:

  • Application layer (Шар застосунку) — відповідає за бізнес-логіку, специфічну для задачі, яку ми вирішуємо за допомогою застосунку.
  • Middleware/Driver layer (Шар проміжного ПЗ / драйверів) — виступає містком між апаратно-незалежною бізнес-логікою та апаратно-залежними інтерфейсами.
  • Hardware Abstraction Layer, HAL (Шар абстракції апаратного забезпечення) — відокремлює верхні шари від конкретної апаратної архітектури. Це реалізується за допомогою двох компонентів: трейтів embedded-hal та специфічних для конкретного заліза реалізацій цих трейтів (наприклад, stm32f4xx-hal). Верхні шари знають лише про трейти embedded-hal.
  • Peripheral Access Layer, PAC (Шар доступу до периферії) — найнижчий програмний шар, що знаходиться безпосередньо над фізичними апаратними регістрами. Він забезпечує пряме «сире» цифрове представлення карти регістрів мікроконтролера.
  • Hardware (Апаратне забезпечення) — представляє реальні фізичні електронні компоненти системи.
  • Initialization (Ініціалізація) — це спеціальний компонент, він відповідає за створення конкретних реалізацій трейтів HAL, підключення їх з драйверами, а також передачу повністю зібраної системи до шару застосунку для запуску виконання.

Ми ще неодноразово будемо повертатись до цієї архітектури, щоб підкреслити, як різні підходи до тестування вписуються в таку схему, які саме частини тестуватимуться і яким чином.

3. Демонстраційний проєкт

Наступним кроком є опис нашого демонстраційного прикладу. Ми створимо дуже простий проєкт для вимірювання температури та виведення результату.

Апаратні компоненти системи:

  • Мікроконтролер STM32F446RE
  • Барометр BME280

Бізнес-логіка складається з простого фільтра ковзного середнього (moving average filter). Наша мета — не створення реального продукту для вирішення реальних задач, а мінімальний застосунок, щоб на практиці побачити, як структурувати тестований код. Розуміння принципів тестування на простому прикладі дозволить легко перенести ці знання на більш складні проєкти.

Структура проєкту

Тепер ми готові налаштувати структуру проєкту відповідно до нашої архітектури. Ми розділимо компоненти на робочі простори (workspaces) Rust.

  • Структура каталогів:
.
├── Cargo.toml
├── app
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── bme280-driver
│   ├── Cargo.toml
│   └── src
│       ├── baro.rs
│       └── lib.rs
└── stm32-target
    ├── .cargo
    │   └── config.toml
    ├── build.rs
    ├── Cargo.toml
    ├── memory.x
    └── src
        └── main.rs
  • кореневий Cargo.toml
[workspace]
members = [
    "application",
    "bme280-driver",
    "stm32-target",
]

Якщо повернутися до нашої архітектури, то можна побачити, що структура проєкту безпосередньо відображає її шари.

Структура проєкту

Трейти HAL надаються крейтом embedded-hal і не залежать від вибору заліза. Тому на діаграмі самі трейти не входять до пакету stm32-target, на відміну від їх конкретних реалізацій.

Весь апаратно-залежний код знаходиться в пакеті stm32-target, і конфігурується в файлах:

  • .cargo/config.toml — локальний файл конфігурації Cargo, де ми вказуємо цільову платформу (target) для крос-компіляції:
[build]
target = "thumbv7em-none-eabihf"
  • build.rs — містить необхідні параметри для лінкувальника
  • memory.x — визначає розміри та адреси Flash-пам’яті та RAM

4. Юніт-тести

Ми почнемо тестування з найпростішої, але важливої частини — модульного (юніт) тестування. За належного розділення архітектурних компонентів, бізнес-логіку та проміжне ПЗ (middleware) можна тестувати незалежно одне від одного й незалежно від нижчих шарів HAL/PAC/апаратури. Ключова перевага: юніт-тести легко писати, вони швидко виконуються і не потребують специфічного заліза. Це робить процес розробки з використанням юніт-тестів надзвичайно комфортним.

Юніт-тести

Rust надає чудову інфраструктуру для юніт-тестів, зазвичай вони розташовуються поруч із кодом, що тестується (тобто в тому самому файлі).

.
├── application
│   └── src
│       └── lib.rs  <---- юніт-тести тут, поруч із кодом, що тестується
└── bme280-driver
    └── src
        └── baro.rs <---- юніт-тести тут, поруч із кодом, що тестується

Розглянемо наступний приклад коду, у файлі application/src/lib.rs ми маємо реалізацію простого фільтра ковзного середнього для зашумлених даних сенсора (структура SMABuffer) — цей простий приклад представляє нашу бізнес-логіку. SMABuffer відокремлений від іншого коду, тому його можна тестувати незалежно.

#[cfg(test)]
mod tests {
    extern crate std;
    use std::prelude::v1::*;
    use crate::*;

    #[test]
    fn average_of_multiple_samples() {
        let mut buffer: SmaBuffer<8> = SmaBuffer::new();
        for i in 0..buffer.capacity() {
            buffer.push(i as i32);
        }
        assert_eq!(buffer.average(), (buffer.capacity() - 1) as i32 / 2);
    }
}

У розробці вбудованих систем робочий код зазвичай орієнтований на середовище «bare-metal» — без операційної системи. Тому ми не можемо використовувати стандартну бібліотеку (std-crate), а лише незалежну від платформи підмножину std-crate, яка називається core-crate. Атрибут #![no_std] вказує компілятору на це обмеження. Проте юніт та інтеграційні тести будуть виконуватись не в середовищі мікроконтролера, а на машині розробника або CI-сервері, де доступна повна стандартна бібліотека.

Саме тому тестовий модуль імпортує std-crate та явно підключає стандартну прелюдію (standard prelude) — усе те, що Rust зазвичай додає в кожен модуль автоматично (Option, Vec, String, Iterator тощо). І це стосується не лише std-crate: будь-який сторонній пакет, який потребує std-crate, також доступний у тестах, навіть якщо його не можна використовувати в робочому коді для мікроконтролера. Як результат, тести позбавлені обмежень цільового середовища виконання, що значно спрощує їх написання.

5. Юніт-тести з використанням моків (Mocks)

Ізольовані компоненти легко тестувати за допомогою звичайних юніт-тестів, як-от наш SMABuffer. Проте дуже часто компоненти, що тестуються, залежать від інших частин системи — наприклад, драйвер барометра залежить від шини I2C. Для цього нам потрібно підмінити залежність моком.

У драйвері барометра ми використовуємо трейт embedded_hal::i2c::I2c. Ми можемо створити мок-реалізацію із захардкодженими відповідями та перевірити, чи правильно драйвер їх обробляє.

Існує чудове відео від Digikey, де детально пояснюється, як це зробити. Проте ми оберемо простіший шлях і використаємо пакет embedded-hal-mock, який уже містить готові мок-реалізації для трейтів embedded-hal. Наприклад, мокання I2C реалізується всього кількома рядками:

let expected = vec![Transaction::write_read(0x77, vec![0xD0], vec![0x42])];
let mut i2c = i2c::Mock::new(&expected);

Розглянемо приклад тесту з використанням моку.

#[cfg(test)]
mod tests {
    extern crate std;
    use std::prelude::v1::*;
    use embedded_hal_mock::eh1::i2c::{self, Transaction};
    use super::*;

    #[test]
    fn read_id() {
        let expected = vec![Transaction::write_read(0x77, vec![0xD0], vec![0x42])];
        let mut i2c = i2c::Mock::new(&expected);
        let baro = Baro::new(BARO_DEVICE_ADDR_SDO_VCC);
        let id = baro.read_id(&mut i2c).unwrap();
        i2c.done();
        assert_eq!(id, 0x42);
    }
}

Окрім перевірки (assert) відповідей від самого компонента, що тестується, ми також можемо пересвідчитися, що до моку були здійснені всі очікувані виклики (i2c.done()).

Як не дивно, головна перевага юніт-тестів — тестування коду в ізоляції — є водночас і їхнім обмеженням. Значна частина складності застосунку криється не в самих компонентах, а у взаємодії між ними. Цю частину ми розглянемо в наступному розділі — інтеграційні тести. Другим обмеженням є те, що юніт-тести не перевіряють роботу на реальному апаратному забезпеченні.

6. Інтеграційні тести

Rust також має вбудовану інфраструктуру для інтеграційних тестів. Вони залежать від розділення шарів архітектури навіть сильніше, ніж юніт-тести. Головну увагу тут слід приділити розділенню шарів HAL та драйверів. Стандартне рішення — визначати залежності від трейтів embedded-hal, а не на їх конкретних реалізацій, що дозволяє замінювати реальні імплементації моками під час тестування. Це вимагає певного налаштування, але зберігає ключову перевагу юніт-тестів: високу швидкість виконання.

Інтеграційні тести

Оскільки інтеграційні тести не належать до якогось конкретного шару чи компонента, ми виносимо їх в окремий пакет, додаючи новий вокрспейс до кореневого файлу Cargo.toml.

[workspace]
members = [
    "bme280-driver",
    "stm32-target",
    "application",
    "integration-tests", # <-- додано
]

Ми також змінюємо структуру проєкту, додаючи новий пакет — integration-tests.

.
├── application
├── bme280-driver
├── stm32-target
└── integration-tests                   <-- додано
    ├── Cargo.toml                      <-- додано
    └── tests                           <-- додано
        └── filtered_sensor_tests.rs    <-- додано

Розглянемо приклад інтеграційного тесту.

mod tests {
    use application::SmaBuffer;
    use bme280_driver::Baro;
    use embedded_hal_mock::eh1::i2c::{self, Transaction};

    #[test]
    fn filtered_temperature_basic_test() {
        let dummy_i2c_address = 0x42;
        let expected = vec![
            Transaction::write_read(dummy_i2c_address, vec![0x88], vec![0; 24]),
            Transaction::write(dummy_i2c_address, vec![0xF4, 0x27]),
            Transaction::write_read(dummy_i2c_address, vec![0xF7], vec![0; 6]),
        ];
        let mut i2c_mock = i2c::Mock::new(&expected);
        let baro = Baro::new(dummy_i2c_address)
            .calibrated(&mut i2c_mock)
            .unwrap();
        let mut sma_buffer: SmaBuffer<8> = SmaBuffer::new();
        let sensor_data = baro.read_sensor(&mut i2c_mock).unwrap();
        sma_buffer.push(sensor_data.temperature);
        i2c_mock.done();
        assert_eq!(sma_buffer.average(), sensor_data.temperature);
    }
}

Тут ми використовуємо компоненти з різних шарів (application та bme280_driver), тому це вже не є тестуванням в ізоляції. Інтеграційні тести частково вирішують проблему перевірки складності, зосередженої у взаємодії між компонентами. Проте той факт, що вони виконуються на машині розробника або CI-сервері, не дозволяє нам протестувати шари застосунку, які взаємодіють безпосередньо із залізом: код ініціалізації, реальні реалізації HAL та PAC (затінені частини діаграми).

7. Апаратне тестування — Hardware in the Loop

Стабільність вбудованої системи значною мірою залежить від її апаратної частини (правильної ініціалізації та конфігурації). Ми намагались абстрагуватись від заліза, але найменша помилка в налаштуваннях під час ініціалізації може призвести до некоректної роботи всієї системи.

Апаратні тести

У нас уже є частина проєкту, яка не абстрагована від заліза — це пакет stm32-target. Саме він і стане місцем де можна розмістити тести для конкретної апаратної платформи.

Призначення цих тестів:

  • переконатись, що етап ініціалізації (або окремі його частини) працює належним чином, а зміни в коді не призводять до регресій;
  • упевнитись, що зміни в самому залізі (наприклад, новий мікроконтролер, інша схема підключення виводів тощо) працюють коректно.

Оновімо проєкт та додамо тести для цільової платформи (файл initialization_tests.rs).

.
├── application
├── bme280-driver
├── integration-tests
└── stm32-target
    ├── build.rs
    ├── Cargo.toml
    ├── memory.x
    ├── src
    │   └── main.rs
    └── tests
        └── initialization_tests.rs    <-- додано

Для тестування на реальному залізі ми використаємо пакет embedded-test. Це потребує певного попереднього налаштування:

1. Додати dev-залежність до Cargo.toml пакету

[dev-dependencies]
embedded-test = { version = "0.7.1", features = ["defmt"] }

2. Відключити стандартний тест-ранер для кожного файлу тестів у Cargo.toml пакету (наразі у нас лише один такий файл — initialization_tests.rs)

[[test]]
name = "initialization_tests"
harness = false

3. Додати інструкцію для лінкувальника у файл build.rs

fn main() {
    println!("cargo::rustc-link-arg=--nmagic");
    println!("cargo::rustc-link-arg=-Tlink.x");
    println!("cargo::rustc-link-arg=-Tdefmt.x");

    // додаємо директорію stm32-target до шляхів пошуку лінкувальника, щоб він знайшов memory.x
    println!("cargo::rustc-link-search=stm32-target");
    // апаратні тести
    println!("cargo::rustc-link-arg-tests=-Tembedded-test.x"); // <-- додано
}

Розглянемо приклад апаратного тесту та звернемо увагу на кілька важливих моментів.

#![no_std]
#![no_main]

#[embedded_test::tests]
mod tests {
    use bme280_driver::Baro;
    use bme280_driver::baro::BARO_DEVICE_ADDR_SDO_VCC;
    use defmt::assert_eq;
    use defmt_rtt as _;
    use stm32f4xx_hal::{
        gpio::GpioExt,
        i2c,
        prelude::*,
    };

    struct Peripherals {
        core_peripherals: cortex_m::Peripherals,
        device_peripherals: stm32f4xx_hal::pac::Peripherals,
    }

    #[init]
    fn init() -> Peripherals {
        Peripherals {
            core_peripherals: cortex_m::Peripherals::take().unwrap(),
            device_peripherals: stm32f4xx_hal::pac::Peripherals::take().unwrap(),
        }
    }

    #[test]
    fn bme280_connection_test(peripherals: Peripherals) {
        let dp = peripherals.device_peripherals;
        let mut rcc = dp.RCC.constrain();
        let gpiob = dp.GPIOB.split(&mut rcc);
        let i2c1_scl = gpiob.pb6.into_alternate_open_drain::<4>();
        let i2c1_sda = gpiob.pb7.into_alternate_open_drain::<4>();

        let mut i2c = stm32f4xx_hal::i2c::I2c::new(
            dp.I2C1,
            (i2c1_scl, i2c1_sda),
            i2c::Mode::standard(100.kHz()),
            &mut rcc,
        );
        let bme280 = Baro::new(BARO_DEVICE_ADDR_SDO_VCC)
            .calibrated(&mut i2c)
            .unwrap();
        let id = bme280.read_id(&mut i2c).unwrap();
        assert_eq!(id, 0x60);
    }
}
  1. Тест буде виконуватись на цільовому пристрої, тому атрибут #![no_std] є обов’язковим. Cтандартну бібліотеку в цих тестах використовувати не можна.
  2. Використовується test-runner embedded-test за допомогою атрибута #[embedded_test::tests].
  3. Спільна логіка налаштування винесена у функцію fn init(), а структура Peripherals буде доступна як аргумент для тестових методів.
  4. Тест перевіряє частину операцій етапу ініціалізації застосунку: fn bme280_connection_test(peripherals: Peripherals).
  5. Для гарного форматування повідомлень про помилки перевірки (assert) використовується макрос defmt::assert_eq.

Для запуску апаратних тестів необхідно виконати наступну команду (пристрій має бути належним чином підключений).

(cd stm32-target && cargo test)

Після цього ми отримаємо гарний та зрозумілий звіт про тестування

Finished `test` profile [unoptimized + debuginfo] target(s) in 1.32s
  Running tests/initialization_tests.rs (testing_in_embedded_with_rust/target/thumbv7em-none-eabihf/debug/deps/initialization_tests-b950e2b189e37a02)
      Erasing ✔ 100% [####################]  64.00 KiB @  43.24 KiB/s (took 1s)
  Programming ✔ 100% [####################]  60.00 KiB @  33.89 KiB/s (took 2s)     Finished in 3.36s

running 1 test
test tests::bme280_connection_test ... [INFO ] Test exited with () or Ok(..) (embedded_test embedded-test-0.7.1/src/fmt.rs:36)
ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s

Нажаль, апаратні тести мають певні обмеження:

  1. Вони залежать від конкретної апаратної платформи — неможливо написати один такий тест для всіх архітектур одразу. Втім, у цьому й полягає їхній сенс: враховувати особливості заліза й переконатись, що ми правильно їх розуміємо.
  2. Виконання займає більше часу через необхідність прошивання мікроконтролера та виконання на повільнішому процесорі.
  3. Їх важче налаштувати, адже потрібне фізичне з’єднання компонентів, що може стати проблемою у віддаленому середовищі.
  4. Складно інтегрувати в конвеєр CI (хоча й можливо, див. чудову статтю від Ferrous Systems).
  5. Важко запускати паралельно.
  6. Обмежені пакетом core-crate, обмежений доступ до стандартної бібліотеки Rust та сторонніх пакетів, які її потребують.

Зважаючи на ці обмеження, сферу застосування тестів на залізі варто звузити до перевірки ініціалізації, наскрізних (full-stack) інтеграційних перевірок та апаратних нюансів, які варто зафіксувати в тестах. Для всього іншого краще обирати юніт або інтеграційні тести.

Підсумок

Тестування вбудованого програмного забезпечення має критичне значення: дефекти, виявлені на етапі експлуатації, дорого виправляти, а інколи й просто неможливо усунути. Хоча Rust допомагає знизити кількість дефектів, виявляючи цілий клас помилок конфігурації апаратури ще на етапі компіляції, він не може перевірити бізнес-логіку — для цього нам потрібні тести.

Правильна архітектура — це фундамент для побудови тестованих вбудованих застосунків. Завдяки їй можна застосовувати три типи тестів, розглянуті у цій статті: юніт-тести для ізольованих компонентів, інтеграційні тести для взаємодії між ними та апаратні тести для реального заліза. Кожен тип виявляє різні класи помилок, а разом вони формують цілісну стратегію тестування. Вона зміщує виявлення дефектів на ранні етапи розробки — де, як показує крива Боема, вартість їх виправлення є мінімальною.

Вихідний код.

Ресурси

  1. Розділ про автоматизовані тести в The Rust Book
  2. Чудове пояснення важливості портабельності з The Embedded Rust Book
  3. Стаття про тестування у вбудованих системах на Rust від Ferrous Systems, з прикладами апаратного тестування за допомогою defmt-test замість embedded-test
  4. Відео DigiKey про тестування вбудованих систем на Rust

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Чудова стаття, дякую за системний підхід. Крива Боема — саме те, чому ми взагалі маємо думати про тестування на ранніх етапах. Rust з його type system чудово закриває memory safety і апаратну конфігурацію, але, як ви правильно зазначили, бізнес-логіку він не верифікує.
Хочу додати ще одну прогалину в цій піраміді: temporal collisions. Навіть юніт-тести + інтеграційні + HIL не ловлять race conditions між компонентами, які працюють з різними таймінгами або через асинхронні канали. Наприклад, сенсор записав значення в регістр, але application шар прочитав його до того, як драйвер завершив консистентне оновлення — і HIL це не побачить, бо на реальному залізі воно відтворюється раз на 1000 запусків.
Ми дослідили цю проблему з TLA+ (model checking) на прикладі Kafka/NATS/RabbitMQ — виявилось, що тестова піраміда має сліпу зону на рівні міжкомпонентних протоколів, і формальна верифікація її закриває. Деталі в статті «Чому тести не ловлять race conditions і що з цим робити», якщо цікаво: dou.ua/forums/topic/60608

Підписатись на коментарі