Від монолітів до мікросервісів: оновлений посібник з автоматизованого тестування
Мікросервісна архітектура створює унікальні виклики для тестування. У 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 має бути запущений).
- Верифікація провайдерів:
- Java: cd services/data-java && mvn test
- Python: cd services/ai-python && pytest tests/ -k pact
- 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 забезпечують швидкий і передбачуваний процес релізів.
Тож успішний перехід до мікросервісів вимагає:
- комплексного підходу до тестування на всіх рівнях;
- чіткого визначення контрактів між сервісами;
- автоматизації розгортання та тестування.
Підписатися на новини
-
Думка експертаOperational Intelligence - Tech Pulse | Дайджест #2
У цьому випуску ми розглядаємо кілька практичних нюансів OpenTelemetry, проблему з якістю даних, оновлення від провайдерів і хто відповідає за які частини observability-стеку.
-
Думка експертаЦифрові двійники в IT: ключові архітектурні патерни та рішення
-
Думка експертаПеревірка етичності AI у фінтехі
-
Лайфхаки
Що таке Operational Intelligence в EPAM і навіщо вам читати Tech Pulse
-
Думка експертаAI в музиці: коли голос стає продуктом
Чому тема «AI в музиці» — це не про заміщення музикантів, а про нові правила гри на ринку, де виробництво контенту тепер практично безкоштовне.