Пропустити навігацію EPAM

Від монолітів до мікросервісів: оновлений посібник з автоматизованого тестування

Єгор Максимчук

Quality Architect I
Думка експерта
  • Testing

Посилання на ресурси автора:

LinkedIn

YouTube

Telegram

GitHub

Мікросервісна архітектура створює унікальні виклики для тестування. У 99% випадків неможливо запустити один окремий мікросервіс для тестування його REST вебсервісів без створення моків усіх пов'язаних із ним сервісів. Це робить процес автоматизованого тестування значно складнішим, ніж у монолітних системах.

У цій статті ми розглядаємо практичний досвід переходу від монолітів до мікросервісів та зосередимось на особливостях автоматизованого тестування. Порівняємо монолітну та мікросервісну архітектуру з погляду тестування, дослідимо різні рівні автоматизації та поділимося нашим підходом до забезпечення стабільності системи через контрактне тестування та ефективні CI/CD процеси.

Порівняння: монолітна та мікросервісна архітектура в тестуванні

Перехід від монолітної до мікросервісної архітектури суттєво змінює підходи до тестування. Нижче — ключові відмінності та схематичне уявлення.

Цілісність проти розподіленості

У монолітній архітектурі всі компоненти зберігаються в одному репозиторії та випускаються як єдине ціле: пошук помилок простіший, налагодження потребує менше зусиль, розгортання для тестування — одна операція. У мікросервісній архітектурі QA стикається з розподіленою системою: кожен сервіс розробляється та розгортається окремо, інтеграційне тестування ускладнюється через розподіленість ресурсів та різноманітні залежності.

Залежності: централізовані vs ізольовані

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

Схема порівняння

Порівняння за ключовими ознаками:

Склад монолітного додатка (один процес):

Склад мікросервісної системи (демо-проєкт):

ПРИЄДНУЙСЯ ДО НАШОЇ КОМАНДИ

Рівні автоматизованого тестування в мікросервісах

Автоматизоване тестування забезпечує стабільність і якість на різних етапах. Нижче — короткий опис кожного рівня з прикладами коду.

Юніт-тестування окремих сервісів

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

Приклад (Java, JUnit + Mockito): тест сервісу, що повертає питання; репозиторій замокано.

// QuestionServiceTest.java

@ExtendWith(MockitoExtension.class)

class QuestionServiceTest {

 

    @Mock

    private QuestionJpaRepository jpaRepository;

 

    @InjectMocks

    private QuestionService questionService;

 

    @Test

    void getQuestions_returnsMappedResponse() {

        QuestionEntity e = new QuestionEntity("data-1", "Text?", List.of("A", "B", "C", "D"), 0, "Data");

        when(jpaRepository.findByCategoryOrderById("Data")).thenReturn(List.of(e));

 

        QuestionsResponse resp = questionService.getQuestions();

 

        assertThat(resp.getCategory()).isEqualTo("Data");

        assertThat(resp.getQuestions()).hasSize(1);

        assertThat(resp.getQuestions().get(0).getId()).isEqualTo("data-1");

        assertThat(resp.getQuestions().get(0).getText()).isEqualTo("Text?");

    }

}

Приклад (Python, pytest): юніт-тест репозиторію (валідація формату відповіді, correctIndex).

# test_repository.py

def test_create_and_list(session):

    q = {

        "id": "ai-1",

        "text": "Question?",

        "options": ["A", "B", "C", "D"],

        "correctIndex": 0,

    }

    created = repo.create(session, q)

    session.commit()

    listed = repo.list_by_category(session)

    assert len(listed) == 1

    assert listed[0]["id"] == "ai-1"

    assert listed[0]["text"] == "Question?"

    assert listed[0]["correctIndex"] == 0

Функціональне тестування з моками

Моки замінюють реальні компоненти (наприклад, HTTP-клієнт або інший сервіс) і дозволяють перевіряти поведінку одного компонента в різних умовах.

Приклад (TypeScript, Jest + мок HTTP): Gateway викликає провайдера; тест підставляє мок-відповідь і перевіряє, що клієнт повертає очікуваний JSON.

// quiz.test.ts (functional with mocks)

import { getSecurityQuestions } from "./quiz";

 

jest.mock("node-fetch");

const fetch = require("node-fetch");

 

describe("getSecurityQuestions", () => {

  it("returns category and questions from provider response", async () => {

    const mockResponse = {

      category: "Security",

      questions: [

        { id: "sec-1", text: "Q?", options: ["A", "B", "C", "D"], correctIndex: 0 },

      ],

    };

    fetch.mockResolvedValueOnce({

      ok: true,

      json: () => Promise.resolve(mockResponse),

    });

 

    const result = await getSecurityQuestions("http://mock-provider:8080");

 

    expect(result.category).toBe("Security");

    expect(result.questions).toHaveLength(1);

    expect(result.questions[0]).toHaveProperty("id", "sec-1");

    expect(result.questions[0]).toHaveProperty("correctIndex", 0);

  });

});

Інтеграційне тестування з маками

Моки імітують зовнішній сервіс, а тестується реальний код (наприклад, Gateway). Перевіряється взаємодія по HTTP без повного середовища.

Приклад (Java, MockMvc + MockBean): контролер тестується з замоканим сервісом; перевіряються статус та структура JSON.

// QuestionsControllerIntegrationTest.java

@WebMvcTest(QuestionsController.class)

class QuestionsControllerIntegrationTest {

 

    @Autowired

    private MockMvc mockMvc;

 

    @MockBean

    private QuestionService questionService;

 

    @Test

    void getQuestions_returnsOk() throws Exception {

        Question q = new Question("data-1", "Text?", new String[]{"A", "B", "C", "D"}, 0);

        QuestionsResponse resp = new QuestionsResponse("Data", List.of(q));

        when(questionService.getQuestions()).thenReturn(resp);

 

        mockMvc.perform(get("/questions"))

                .andExpect(status().isOk())

                .andExpect(jsonPath("$.category").value("Data"))

                .andExpect(jsonPath("$.questions[0].id").value("data-1"));

    }

}

Приклад інтеграції Gateway + фейк-провайдер (Node + nock): фейк відповідає на GET /questions, Gateway робить реальний запит, і тест перевіряє статус та тіло.

// gateway.integration.test.ts

import nock from "nock";

import request from "supertest";

import { app } from "../index";

 

describe("GET /api/questions/security", () => {

  it("returns 200 and body with category and questions", async () => {

    nock("http://security-service:8080")

      .get("/questions")

      .reply(200, {

        category: "Security",

        questions: [{ id: "s1", text: "Q?", options: ["A", "B", "C", "D"], correctIndex: 0 }],

      });

 

    const res = await request(app).get("/api/questions/security");

 

    expect(res.status).toBe(200);

    expect(res.body).toHaveProperty("category", "Security");

    expect(res.body.questions).toHaveLength(1);

  });

});

E2E-тестування всієї системи

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

Приклад (Playwright): відкрити сторінку, вибрати категорію, перевірити наявність питань на сторінці.

// e2e/quiz.spec.ts

import { test, expect } from "@playwright/test";

 

test("user sees questions after selecting category", async ({ page }) => {

  await page.goto("http://localhost:3000");

  await page.click('button:has-text("Security")');

  await expect(page.locator("[data-testid=question-list]")).toBeVisible();

  const firstQuestion = page.locator("[data-testid=question-text]").first();

  await expect(firstQuestion).toContainText(/./);

});

Контрактне тестування як основа стабільності API

Контракт — це угода між споживачем (consumer) і провайдером (provider): які поля в запиті/відповіді, формати даних, коди статусів. Контрактне тестування перевіряє відповідність цій угоді без повного E2E.

Що таке контракт

Контракт регламентує: поля запитів і відповідей, допустимі формати, коди статусів, обов’язкові параметри. Для GET /questions у демо-проєкті: відповідь містить category (Security | Data | AI) і масив questions з об’єктами { id, text, options, correctIndex }.

Інструменти: Pact, Postman, Swagger

Pact — популярний інструмент для контрактів: consumer генерує Pact-файли, provider верифікує відповідність. Postman та Swagger допомагають тестувати та документувати API.

Приклад: контрактний тест споживача (Gateway, Pact JS)

Споживач описує очікування: «при запиті GET /questions провайдер повертає 200 і JSON з category та questions». Pact піднімає mock provider і перевіряє виклик реального клієнта Gateway.

// gateway/src/api/quiz.pact.test.ts

import { Pact, Matchers } from "@pact-foundation/pact";

import { getSecurityQuestions } from "./quiz";

 

const { eachLike, like } = Matchers;

 

describe("Gateway Pact consumer - security-go", () => {

  const provider = new Pact({

    consumer: "gateway",

    provider: "security-go",

    dir: pactDir,

    port: 0,

  });

 

  beforeAll(() => provider.setup());

 

  it("GET questions returns Security questions", async () => {

    const body = {

      category: "Security",

      questions: eachLike(

        { id: like("sec-1"), text: like("What is Azure AD used for?"), options: eachLike("string"), correctIndex: like(0) },

        4

      ),

    };

 

    await provider

      .addInteraction()

      .given("questions exist")

      .uponReceiving("GET questions")

      .withRequest("GET", "/questions")

      .willRespondWith(200, (builder) => {

        builder.headers({ "Content-Type": "application/json" });

        builder.jsonBody(body);

      })

      .executeTest(async (mockserver) => {

        const result = await getSecurityQuestions(mockserver.url);

        expect(result.category).toBe("Security");

        expect(result.questions).toHaveLength(4);

        expect(result.questions[0]).toHaveProperty("id");

        expect(result.questions[0]).toHaveProperty("correctIndex");

      });

  });

});

Приклад: верифікація провайдера (Java, Pact JVM)

Провайдер завантажує Pact-файли (з каталогу або Broker) і перевіряє, що його API відповідає контракту.

// ContractVerificationTest.java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

@ActiveProfiles("test")

@Provider("data-java")

@PactFolder("pacts")

class ContractVerificationTest {

 

    @LocalServerPort

    private int serverPort;

 

    @BeforeEach

    void before(PactVerificationContext context) {

        context.setTarget(new HttpTestTarget("localhost", serverPort));

    }

 

    @TestTemplate

    @ExtendWith(PactVerificationSpringProvider.class)

    void pactVerificationTestTemplate(PactVerificationContext context) {

        context.verifyInteraction();

    }

}

Приклад: верифікація провайдера (Python, pact-python)

Провайдер запускається локально (наприклад, uvicorn у фікстурі), верифікатор читає Pact-файл і перевіряє відповіді ендпоінтів.

# test_0_pact_provider.py

def test_pact_provider(provider_server):

    pact_dir = os.path.join(os.path.dirname(__file__), "pacts")

    pact_file = os.path.join(pact_dir, "gateway-ai-python.json")

    # ...

    verifier = (

        Verifier("ai-python")

        .add_source(pact_dir)

        .add_transport(url=provider_server)

    )

    verifier.verify()

Скриншоти Pact Broker

Після публікації контрактів (команда npm run pact:publish у каталозі gateway) у Pact Broker з’являються pacticipants та контракти. Нижче — приклад головної сторінки Broker та сторінки контракту.

Головна сторінка Pact Broker (список pacticipants):

Сторінка контракту між gateway та провайдером:

Демо-проєкт: архітектура та компоненти

Демо ілюструє контрактне тестування та різні рівні тестів у мікросервісній системі з одним клієнтом (Gateway) і трьома провайдерами питань (Security, Data, AI).

Компоненти

Усі три провайдери реалізують однаковий контракт: відповідь містить поле category і масив questions з об’єктами { id, text, options, correctIndex }. Питання можуть зберігатися в коді або в БД (у демо — у коді або невеликій БД без окремого зовнішнього сховища для простоти).

Техстек

  • Gateway: Node.js, TypeScript, Express (або FastAPI для порівняння не використовується), Pact JS.
  • Провайдери: Go, Java (Spring Boot), Python (FastAPI).
  • Контракти: Pact (pact-js, pact-jvm, pact-python, pact-go).
  • Інфраструктура: Docker Compose.

Як запустити

  • У корені репозиторію: docker compose up -d (переконайтеся, що порт 3000 вільний).
  • Фронт: http://localhost:3000
  • Pact Broker: http://localhost:9292
  • Контрактні тести споживача: cd gateway && npm run test:pact
  • Публікація контрактів: npm run pact:publish (Broker має бути запущений).
  • Верифікація провайдерів:
  1. Java: cd services/data-java && mvn test
  2. Python: cd services/ai-python && pytest tests/ -k pact
  3. Go: cd services/security-go && go test -tags=pact ./...

Опис контракту та ендпоінтів (усі провайдери реалізують однаковий контракт):

  • Endpoint: GET /questions
  • Response: status 200, Content-Type: application/json, тіло з полями category (string: Security | Data | AI) та questions (масив об'єктів).
  • Об'єкт питання: id (string), text (string), options (string[]), correctIndex (number, 0–3).

Приклад відповіді:

{

  "category": "Security",

  "questions": [

    {

      "id": "q1",

      "text": "What is Azure AD?",

      "options": ["Identity and access management", "Storage service", "Compute service", "Network service"],

      "correctIndex": 0

    }

  ]

}

Приклади тестів різного рівня

У цьому розділі зібрані приклади тестів з командами запуску та фрагментами коду для демо-проєкту.

Юніт-рівень

Мета: перевірити логіку сервісу або репозиторію без мережі та зовнішніх сервісів (наприклад, валідація correctIndex або формату відповіді).

Команда запуску: Java: cd services/data-java && mvn test -Dtest=QuestionServiceTest · Python: cd services/ai-python && pytest tests/test_repository.py -v

@Test

void getQuestions_returnsMappedResponse() {

    QuestionEntity e = new QuestionEntity("data-1", "Text?", List.of("A", "B", "C", "D"), 0, "Data");

    when(jpaRepository.findByCategoryOrderById("Data")).thenReturn(List.of(e));

    QuestionsResponse resp = questionService.getQuestions();

    assertThat(resp.getCategory()).isEqualTo("Data");

    assertThat(resp.getQuestions()).hasSize(1);

    assertThat(resp.getQuestions().get(0).getId()).isEqualTo("data-1");

}

def test_create_and_list(session):

    q = {"id": "ai-1", "text": "Question?", "options": ["A", "B", "C", "D"], "correctIndex": 0}

    repo.create(session, q)

    session.commit()

    listed = repo.list_by_category(session)

    assert len(listed) == 1

    assert listed[0]["id"] == "ai-1"

    assert listed[0]["correctIndex"] == 0

Інтеграційний рівень (контролер + мок сервісу або Gateway + фейк)

Мета: перевірити взаємодію компонентів по HTTP: контролер повертає очікуваний статус і JSON, або Gateway коректно сприймає відповідь фейк-провайдера.

Команда запуску: cd services/data-java && mvn test -Dtest=QuestionsControllerIntegrationTest

@Test

void getQuestions_returnsOk() throws Exception {

    Question q = new Question("data-1", "Text?", new String[]{"A", "B", "C", "D"}, 0);

    QuestionsResponse resp = new QuestionsResponse("Data", List.of(q));

    when(questionService.getQuestions()).thenReturn(resp);

    mockMvc.perform(get("/questions"))

            .andExpect(status().isOk())

            .andExpect(jsonPath("$.category").value("Data"))

            .andExpect(jsonPath("$.questions[0].id").value("data-1"));

}

Контрактний рівень (Pact)

Мета: переконатися, що consumer і provider згодні з контрактом (формат запиту/відповіді для GET /questions).

Consumer (фрагмент):

await provider

  .addInteraction()

  .given("questions exist")

  .uponReceiving("GET questions")

  .withRequest("GET", "/questions")

  .willRespondWith(200, (builder) => {

    builder.headers({ "Content-Type": "application/json" });

    builder.jsonBody(body);

  })

  .executeTest(async (mockserver) => {

    const result = await getSecurityQuestions(mockserver.url);

    expect(result.category).toBe("Security");

    expect(result.questions[0]).toHaveProperty("correctIndex");

  });

Provider Java (фрагмент):

@Provider("data-java")

@PactFolder("pacts")

class ContractVerificationTest {

    @TestTemplate

    @ExtendWith(PactVerificationSpringProvider.class)

    void pactVerificationTestTemplate(PactVerificationContext context) {

        context.verifyInteraction();

    }

}

E2E-рівень

Мета: перевірити сценарій від початку до кінця: користувач відкриває сторінку, обирає категорію, бачить питання (наприклад, текст першого питання).

Команда запуску: npx playwright test e2e/quiz.spec.ts (перед цим запустити docker compose up -d або локальний фронт і сервіси)

import { test, expect } from "@playwright/test";

 

test("user sees questions after selecting category", async ({ page }) => {

  await page.goto("http://localhost:3000");

  await page.click('button:has-text("Security")');

  await expect(page.locator("[data-testid=question-list]")).toBeVisible();

  const firstQuestion = page.locator("[data-testid=question-text]").first();

  await expect(firstQuestion).toContainText(/./);

});

Автоматизація в CI/CD

Побудова ефективної CI/CD є основою стабільних релізів у мікросервісній архітектурі.

  • Окреме середовище для автотестів: виділене середовище для автоматизованих тестів зменшує вплив на прод і підвищує якість перевірок; при запуску тестів обирається параметр оточення (test/prod).
  • Паралельне тестування: пайплайн паралельно запускає збірки та тести незалежних сервісів (юніт, інтеграційні, контрактні), що прискорює зворотний зв’язок.
  • Моніторинг і логи: після деплою збираються метрики та логи (наприклад, Prometheus, ELK); при падінні тестів або помилках у прод можна налаштувати сповіщення (email, Slack тощо).

У демо-проєкті контрактні тести можна інтегрувати в CI так: спочатку consumer генерує Pact-файли (npm run test:pact), потім публікує їх у Broker (npm run pact:publish), далі кожен провайдер у своєму пайплайні верифікує контракт (mvn test / pytest / go test).

Висновок

Перехід від монолітної до мікросервісної архітектури значно змінює підходи до автоматизованого тестування. Мікросервіси потребують більше ресурсів і складніші в тестуванні, але дають масштабованість і гнучкість. Завдяки рівням тестування — від юніт-тестів до E2E — та використанню моків і фейків можна ефективно перевіряти компоненти та їх взаємодію без зайвого розгортання всього середовища.

Контрактне тестування (Pact, Postman, Swagger) стало ключовим для стабільності API: чіткі контракти між сервісами знижують ризики несумісності при оновленнях. Окреме середовище для автотестів та паралельний CI/CD забезпечують швидкий і передбачуваний процес релізів.

Тож успішний перехід до мікросервісів вимагає:

  • комплексного підходу до тестування на всіх рівнях;
  • чіткого визначення контрактів між сервісами;
  • автоматизації розгортання та тестування.

Підписатися на новини

Чудово! Ми вже готуємо добірку актуальних новин для вас :)

Вибачте, щось пішло не так. Будь ласка, спробуйте ще раз.

* Обов'язкові поля

*Будь ласка, заповніть обов’язкові поля