NoSQL для QA: MongoDB, Redis і консистентність
Зміст
Реляційну базу QA бачить наскрізь: таблиці, зовнішні ключі, JOIN, транзакція або пройшла, або відкотилася. Але щойно застосунок додає MongoDB для профілів користувачів, Redis для кешу й починає читати дані з репліки — старі інтуїції ламаються. Запис пройшов, а тест його не бачить. Документ у колекції має поле, якого немає в сусіднього документа. Кнопка показує стару ціну, хоча бекенд її вже оновив. Це не баги застосунку — це поведінка NoSQL-сховищ, яку треба розуміти, щоб не заводити фальшиві дефекти й не писати флакі-тести (flaky tests).
На співбесіді senior-QA питають NoSQL не для того, щоб ви написали агрегацію в MongoDB напам'ять. Питають, щоб перевірити, чи розумієте ви компроміси: чому гнучка схема економить час розробнику й породжує клас багів для тестувальника, чому кеш пришвидшує систему й водночас бреше, чому «дані ж записалися» не дорівнює «дані видно всюди». Це розмова про консистентність, а не про синтаксис.
SQL vs NoSQL: що насправді відрізняється
NoSQL — не «база без SQL» і не «база без схеми». Це парасольковий термін для сховищ, які свідомо відмовилися від якоїсь частини реляційної моделі заради іншої вигоди: горизонтального масштабування, гнучкості структури або швидкості на конкретному патерні доступу.
Головна відмінність — де живе схема. Реляційна БД використовує schema-on-write: структуру визначено наперед у CREATE TABLE, і сервер відхилить рядок, що їй не відповідає (див. DDL і обмеження цілісності). Багато NoSQL-сховищ працюють за schema-on-read: сховище прийме будь-що, а розбиратися зі структурою мусить код, який ці дані читає. Схема нікуди не зникла — вона просто переїхала з сервера в застосунок і в голову розробника. Для QA це означає, що гарантій цілісності, які раніше давала БД, тепер може не бути взагалі.
| Аспект | Реляційна (SQL) | Типова NoSQL |
|---|---|---|
| Модель даних | Таблиці, рядки, зв'язки | Документи / ключ-значення / граф / колонки |
| Схема | Жорстка, на запис | Гнучка, на читання |
| Мова запитів | Стандартний SQL | У кожної своя (API, JSON-фільтри, команди) |
| Зв'язки | JOIN на боці БД | Денормалізація або з'єднання в коді |
| Масштабування | Історично вертикальне | Здебільшого горизонтальне (шардинг) |
| Гарантії | Здебільшого ACID | Часто BASE / eventual consistency |
ACID (див. Транзакції та ACID) — це строгість: транзакція атомарна й лишає БД у консистентному стані. Багато розподілених NoSQL-сховищ натомість сповідують BASE — Basically Available, Soft state, Eventual consistency: система завжди відповідає, стан тимчасово може бути неузгодженим, але «зрештою» вирівняється.
Важлива засторога проти застарілих кліше: межа розмилася. PostgreSQL вміє зберігати й індексувати JSONB, а MongoDB від версії 4.0 підтримує багатодокументні ACID-транзакції. Тож «SQL = надійно, NoSQL = швидко» — не аргумент рівня senior. Правильна відповідь завжди «залежить від моделі доступу й налаштувань».
Типи NoSQL оглядово
«NoSQL» об'єднує щонайменше чотири різні моделі, і плутати їх — типова помилка на співбесіді. Redis і MongoDB — це не «дві схожі NoSQL-бази», а зовсім різні інструменти для різних задач.
| Тип | Модель | Приклади | Типове застосування |
|---|---|---|---|
| Документна | JSON-подібні документи | MongoDB, CouchDB | Профілі, каталоги, гнучкі сутності |
| Ключ-значення | key → value | Redis, Memcached, DynamoDB | Кеш, сесії, лічильники, черги |
| Ширококолонкова | Рядки з динамічними колонками | Cassandra, HBase | Логи, часові ряди, величезні обсяги |
| Графова | Вузли й ребра | Neo4j | Соцзв'язки, рекомендації, маршрути |
Для QA практичний висновок такий: перш ніж тестувати, з'ясуйте, яка це модель і яку гарантію консистентності вона дає в цій конфігурації. Від цього залежить, чи можна одразу після запису читати результат, і звідки можуть узятися застарілі дані.
MongoDB: документи, find і гнучка схема як клас багів
MongoDB зберігає дані не рядками в таблицях, а документами в колекціях (collections). Документ — це структура на кшталт JSON (насправді бінарний формат BSON), у якої є вкладеність і масиви. У кожного документа є поле _id — унікальний ідентифікатор (за замовчуванням тип ObjectId).
Запити пишуться не мовою SQL, а через API з фільтром-документом. Аналог SELECT * FROM users WHERE status = 'active' виглядає так:
db.users.find({ status: "active" })
Фільтр — це документ, де ключ це поле, а значення — умова. Оператори теж документи: db.orders.find({ total: { $gt: 100 } }) поверне замовлення з сумою понад 100. Уже тут криється перша пастка QA: якщо в базі total записано рядком "100", а фільтр порівнює з числом 100, документ не знайдеться — MongoDB не робить неявного приведення типів між рядком і числом. Тест «нічого не повертає», а даних насправді повно.
Але головне для тестувальника — гнучка схема як окремий клас багів. У межах однієї колекції документи можуть мати різний набір полів. Розробник додав поле phone до нових користувачів — але тисячі старих документів його не мають. Формально це не помилка бази: MongoDB приймає обидва варіанти. Наслідки:
- Код, який очікує
user.phone, отримуєundefinedна старих документах і падає або мовчки поводиться інакше. - Поле перейменували (
isActive→status), нові документи пишуть по-новому, старі лишилися по-старому, а міграції не було. Половина даних читається неправильно. nullу полі й відсутнє поле — різні стани, які легко сплутати: фільтр рівності{ phone: null }контрінтуїтивно знаходить обидва (і явнийnull, і документи без поля), а розрізняють їх лише оператори$existsта$type. Тест, якому потрібен один конкретний стан, має фільтрувати явно.
MongoDB уміє валідувати схему ($jsonSchema на рівні колекції), але це опційно й часто не ввімкнено. Тому перевірка узгодженості документів — реальна робота QA, а не гарантія бази. Це той самий ризик денормалізації й розсинхрону, що й у проєктуванні схеми, тільки без сітки безпеки з боку сервера.
Коли assert у базі доречний (наприклад, треба переконатися, що дія в UI справді створила документ), підключення з автотесту виглядає звично:
import { MongoClient } from "mongodb";
const client = await MongoClient.connect(process.env.MONGO_URI!);
const users = client.db("shop").collection("users");
const doc = await users.findOne({ email: "qa@example.com" });
expect(doc?.status).toBe("active");
Дисципліна read-only на спільних стендах діє так само, як для реляційних БД (див. БД і QA): перевіряємо стан, не псуємо чужі дані.
Redis як кеш: TTL, інвалідація і «протухлий кеш»
Redis — це сховище ключ-значення, яке тримає дані в оперативній пам'яті (може й скидати на диск, але працює насамперед у RAM). Звідси його швидкість і головне застосування — кеш перед повільнішою основною БД, а також сесії, лічильники й черги. Команди виконуються фактично в одному потоці, тож окрема операція атомарна.
Класичний патерн — cache-aside («кеш збоку»): застосунок спершу питає Redis, і лише за промаху йде в основну БД, а результат кладе в кеш на майбутнє.
Щоб кеш не тримав дані вічно, ключам ставлять TTL (time to live) — час життя в секундах. Команда SET price:42 99 EX 60 кладе значення на 60 секунд; TTL price:42 показує, скільки лишилося; коли час минув, ключ зникає сам. TTL — це найпростіша, «пасивна» інвалідація: дані оновляться не миттєво, а протягом вікна TTL.
Звідси головний біль — інвалідація кешу й «протухлий» (stale) кеш. Дані в основній БД змінилися, а в Redis лежить стара копія, бо:
- TTL великий, а активної інвалідації немає — стара ціна висить хвилини;
- код оновив БД, але забув оновити чи видалити ключ (крок з пунктиру на діаграмі пропущено);
- інвалідація прилетіла, але між «прочитали з БД» і «записали в кеш» встиг втрутитися новий запис (гонка, race condition).
Для QA це означає конкретну тактику: якщо тест бачить старе значення, перша гіпотеза — не баг логіки, а кеш. Треба знати, який TTL у ключа, чи є примусова інвалідація, і чи не читає ваш тест кешовану відповідь замість свіжої. Це серверний аналог того самого «тест бачить стару версію», що й у HTTP-кешуванні браузера, тільки шар інший: там кеш між браузером і мережею, тут — між застосунком і базою. Плутати їх не можна: чистий browser context у Playwright прибирає браузерний кеш, але на Redis усередині бекенда не впливає ніяк.
Eventual consistency: чому запис є, а його не видно
Розподілені сховища зберігають кілька копій даних (реплік) заради надійності й масштабу. Запис іде на головний вузол (primary), а на репліки розповзається асинхронно. Проміжок між «записано на primary» і «видно на репліці» називають лагом реплікації (replication lag). Якщо застосунок читає з репліки одразу після запису, він може отримати старі дані — і це не збій, а штатна поведінка, яку називають eventual consistency: копії узгодяться «зрештою», а не миттєво.
У MongoDB це керовано. Рівень підтвердження запису (write concern) визначає, скільки вузлів мають підтвердити запис; починаючи з версії 5.0, за замовчуванням це підтвердження більшістю вузлів. Куди читати, задає read preference: за замовчуванням читання йде з primary й тоді узгоджене, але якщо явно спрямувати читання на secondary заради розвантаження — можна впіймати застарілі дані. Тобто «баг неузгодженості» часто насправді закладено в конфігурацію.
Практичний висновок для автотестів прямий: не покладайтеся на миттєву видимість запису й не лікуйте це sleep-ом. Правильний підхід — полінг (polling) з таймаутом: перевіряти умову з інтервалом, поки вона не стане істинною або не збіжить час. Фіксована затримка або зробить тест повільним, або лишить його флакі, бо лаг непостійний. Цю ж логіку «дочекайся консистентності, а не спи» ви зустрінете і в автотестах з БД, і в API-тестуванні, де полінг замінює sleep за тією самою причиною.
CAP-теорема на пальцях
CAP-теорема (сформульована Еріком Брюером, доведена Гілбертом і Лінчем 2002 року) описує розподілену систему трьома властивостями:
- C — Consistency (узгодженість): кожне читання бачить останній запис або помилку.
- A — Availability (доступність): кожен запит отримує відповідь (не помилку), хай навіть без гарантії свіжості.
- P — Partition tolerance (стійкість до розділення): система працює, навіть коли мережа між вузлами розірвалася.
Суть, яку часто переказують неправильно: це не «оберіть будь-які два з трьох назавжди». У реальній розподіленій системі мережеві розділення (partitions) неминучі, тож P — не опція, від якої можна відмовитися. Справжній вибір настає під час партиції: або зберегти узгодженість і відмовити частині запитів (CP), або лишитися доступним ціною можливо застарілих даних (AP). Коли ж мережа справна, система віддає і C, і A — компроміс включається лише в момент збою.
Класифікувати конкретну СУБД однією парою літер — спрощення: поведінка залежить від конфігурації (рівень підтвердження запису, звідки читаємо). Але як орієнтир: MongoDB із читанням з primary тяжіє до CP — під час партиції радше відмовить у записі, ніж віддасть неузгоджене; Cassandra з типовими налаштуваннями тяжіє до AP — відповість завжди, а розбіжності владнає згодом. Ширше цю ідею розвиває PACELC: якщо є партиція (P), обираємо між A і C; інакше (Else) — між latency і consistency, бо навіть без збою сильніша узгодженість коштує затримки.
Для QA CAP — не абстракція, а джерело сценаріїв: що станеться з формою під час короткого мережевого збою? Система поверне 5xx (CP — обрала узгодженість) чи прийме запит, який потім розійдеться з іншою реплікою (AP)? Розуміння того, який компроміс обрано, підказує, який «баг» під час деградації насправді є задокументованою поведінкою.
Типові помилки
- Виглядає як «загублене поле в базі», а насправді гнучка схема без міграції. Тест падає на
undefined, бо старі документи не мають нового поля. Баг не в тесті й не в базі — бракує міграції даних. - Виглядає як «find нічого не повертає», а насправді не збігся тип. Фільтр шукає число, а в документі рядок (або
ObjectIdпорівнюють із рядком). MongoDB не приводить типи мовчки — збіг не спрацьовує. - Виглядає як баг ціни на UI, а насправді протухлий кеш Redis. Бекенд оновив дані, але ключ не інвалідовано або TTL завеликий. Лікується не в UI, а перевіркою інвалідації кешу.
- Виглядає як «запис пропав», а насправді eventual consistency. Записали на primary, прочитали з репліки до того, як доїхала реплікація. Ловиться полінгом, а не
sleepі не баг-репортом. - Виглядає як падіння сервісу на секунду, а насправді CP-система під час партиції. Система свідомо відмовила в записі, щоб не порушити узгодженість. Це компроміс за дизайном, а не дефект.
- Виглядає як «NoSQL швидше за SQL», а насправді залежить від патерну доступу. Redis-кеш і повне сканування колекції MongoDB — різні всесвіти швидкодії. Абсолютних тверджень тут не буває.
Підсумок
- NoSQL — не «без схеми», а schema-on-read: перевірка цілісності переїхала з БД у застосунок і стала роботою QA.
- MongoDB зберігає гнучкі документи; різнорідність полів у колекції й неявне порівняння типів — окремий клас багів, а не помилка бази.
- Redis — кеш у пам'яті з TTL; головний ризик QA — протухлі дані через слабку інвалідацію, і це серверний, а не браузерний рівень кешу.
- Eventual consistency означає «зрештою», а не «миттєво»: після запису читання з репліки може бути старим — тестуй полінгом, не
sleep. - CAP — це вибір C проти A під час мережевої партиції, а не «два з трьох назавжди»; знання обраного компромісу пояснює поведінку системи під час збоїв.
Що питають на співбесіді
- «У чому різниця між SQL і NoSQL?» Слабка відповідь — «NoSQL без схеми і швидший». Сильна — про schema-on-write проти schema-on-read, ACID проти BASE, і обов'язково «залежить від моделі й задачі», з прикладом, що PostgreSQL має
JSONB, а MongoDB — транзакції. - «Які типи NoSQL знаєте?» Інтерв'юер перевіряє, чи не зливаєте ви Redis і MongoDB в одне. Назвіть чотири моделі (документна, ключ-значення, ширококолонкова, графова) з прикладом і застосуванням кожної.
- «Чим гнучка схема небезпечна для тестування?» Тут чекають клас багів: різні поля в документах однієї колекції,
nullпроти відсутнього поля, розсинхрон після перейменування без міграції. - «Що таке eventual consistency і як її тестувати?» Дивляться, чи не запропонуєте ви
sleep. Правильно — пояснити лаг реплікації й полінг з таймаутом. - «Розкажіть про CAP.» Провал — «оберіть два з трьох». Сильна відповідь — що P неминуча, а вибір між C і A виникає лише під час партиції; бонус — PACELC і приклад CP проти AP.
- «Тест бачить стару ціну — де шукати?» Перевіряють системність: HTTP-кеш браузера, кеш застосунку (Redis) з його TTL та інвалідацією, лаг реплікації — і вміння відрізнити ці рівні.
Джерела
- MongoDB Manual — офіційна документація: документи,
find, валідація схеми, write/read concern, транзакції. - Redis Documentation — офіційна документація: структури даних,
EXPIRE/TTL, патерни кешування й інвалідації. - Seth Gilbert, Nancy Lynch. «Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services» (2002) — формальне доведення CAP-теореми.
- Martin Kleppmann. «Designing Data-Intensive Applications» (O'Reilly, 2017) — канонічна книга про реплікацію, консистентність і компроміси розподілених сховищ.
Примітка: силабус ISTQB CTFL 4.0 не має окремого розділу про NoSQL чи бази даних — це тема практичної роботи QA поза межами базового сертифіката, тож джерела вище канонічніші за силабус для цієї глави.
Що таке NoSQL і чим воно принципово відрізняється від реляційної бази?
NoSQL — збірна назва для сховищ, що пожертвували частиною реляційної моделі заради масштабування, гнучкості структури чи швидкості на своєму патерні доступу; це не «база без SQL» і тим паче не «база без схеми». Ключове питання — хто відповідає за структуру даних. У реляційному світі діє schema-on-write: CREATE TABLE фіксує структуру, і невідповідний рядок сервер просто не прийме. У багатьох NoSQL діє schema-on-read: записати можна що завгодно, а розгрібати структуру доведеться коду, що читає. Тобто схема живе не в базі, а в застосунку — і звичних гарантій цілісності тут ніхто автоматично не забезпечує. Для тестувальника це пряме розширення обов'язків: узгодженість даних доводиться перевіряти самому.
Які основні типи NoSQL-сховищ ти знаєш?
Їх щонайменше чотири, і сильний кандидат наводить кожен із прикладом та задачею. Документні бази (MongoDB, CouchDB) тримають JSON-подібні документи — профілі, каталоги, сутності зі змінною структурою. Сховища ключ-значення (Redis, Memcached, DynamoDB) — найпростіша модель key → value під кеш, сесії, лічильники й черги. Ширококолонкові (Cassandra, HBase) розраховані на логи, часові ряди й загалом величезні обсяги записів із динамічними колонками. Графові (Neo4j) моделюють вузли та ребра — соціальні зв'язки, рекомендації, маршрути. Улюблена перевірка інтерв'юера — чи не вважаєте ви Redis і MongoDB двома схожими базами: моделі в них різні, задачі теж.
Правда, що «SQL надійний, а NoSQL швидкий»?
Ні, це кліше десятирічної давності, і його варто спростувати з прикладами. З одного боку, реляційний PostgreSQL давно працює з документними даними — тип JSONB можна і зберігати, й індексувати. З іншого — MongoDB ще з версії 4.0 має багатодокументні ACID-транзакції, тож «NoSQL без надійності» — теж неправда. Швидкість — не властивість ярлика «SQL чи NoSQL», а функція патерну доступу: діставання ключа з Redis-кешу й повне сканування великої колекції MongoDB відрізняються на порядки. Показова деталь: та сама MongoDB поводиться по-різному залежно від конфігурації — читання з primary узгоджене, а з secondary може віддати застаріле. Тож відповідь рівня senior звучить як «залежить від моделі даних, патерну доступу й налаштувань».
Що таке ACID і BASE, і чим вони відрізняються?
Це два протилежні набори гарантій. ACID (Atomicity, Consistency, Isolation, Durability) обіцяє: транзакція або виконується цілком, або відкочується, а база після неї лишається в коректному стані — так працюють класичні реляційні СУБД. BASE (Basically Available, Soft state, Eventual consistency) — філософія багатьох розподілених NoSQL: відповідь ти отримаєш завжди, але стан системи в конкретний момент може бути неузгодженим і вирівняється лише з часом. Для QA різниця відчутна на практиці: у BASE-системі свіжий запис при читанні з іншої копії даних може бути ще не видимим, і це очікувана поведінка, а не дефект. Водночас жорстко ділити світ на «ACID проти BASE» не варто: транзакції в MongoDB — приклад того, що все вирішує конфігурація конкретної системи.
Як MongoDB зберігає дані і як виглядає запит замість SELECT?
Одиниця зберігання — документ у колекції: JSON-подібна структура (фізично — бінарний BSON) з підтримкою вкладеності й масивів та обов'язковим унікальним _id (за замовчуванням — ObjectId). Замість мови SQL — API, якому передають фільтр у вигляді документа: db.users.find({ status: "active" }) робить те саме, що SELECT * FROM users WHERE status = 'active'. Складніші умови — теж документи, тільки з операторами: { total: { $gt: 100 } } відбере замовлення дорожчі за 100. Висновок ширший за MongoDB: стандартної мови запитів у NoSQL немає, у кожного сховища власний інтерфейс, і це одна з головних відмінностей від реляційного світу.
find нічого не повертає, хоча даних у колекції повно. З чого почати діагностику?
З типів, а не з підозри на зниклі дані. MongoDB порівнює строго: число 100 у фільтрі ніколи не збіжиться з рядком "100" у документі, неявного приведення не буде — запит мовчки поверне порожній результат. Той самий механізм кусає з ідентифікаторами: ObjectId не дорівнює своєму рядковому представленню, шукати треба через new ObjectId(...). Тому перший крок діагностики — відкрити реальний документ і звірити тип поля з типом у фільтрі. Досвід показує, що в більшості таких «багів» винні тестові дані чи сам запит, а не продукт: це NoSQL-версія правила «спершу перевір свій запит, потім звинувачуй базу».
Чому гнучка схема MongoDB — це окремий клас багів для тестувальника?
Бо колекція не гарантує однакової структури: база без заперечень тримає поруч документи з різними наборами полів. Типові сценарії: у нових користувачів з'явилося поле phone, а в тисяч старих його немає — і код, розрахований на нього, дістає undefined; поле перейменували без міграції даних (isActive → status) — і стара частина колекції читається неправильно, хоча жодна помилка ніде не впала. Захист існує — на колекцію можна повісити валідацію $jsonSchema, — але вона опційна, і в реальних проєктах її часто ніхто не вмикав. Висновок для QA: узгодженість структури документів — це те, що в реляційному світі гарантував сервер, а тут стало вашою роботою.
У чому різниця між null у полі й відсутнім полем у документі?
Це два різні стани документа: null — поле існує з порожнім значенням, відсутнє поле — ключа немає взагалі. Пастка в тому, як їх бачать фільтри. Контрінтуїтивно, фільтр рівності { phone: null } знаходить обидва стани одразу — і явний null, і документи без поля; це задокументована поведінка MongoDB. Розрізнити стани можна лише спеціальними операторами: { phone: { $type: "null" } } дає тільки явний null, а { phone: { $exists: false } } — тільки документи без поля. Для тесту це означає, що вибірка «користувачі без телефону» через рівність із null може виявитися ширшою, ніж задумано, а через $exists: false — вужчою. Симптом плутанини — «зайві» чи «зниклі» документи у вибірці, які виглядають як баг застосунку, хоча насправді неточний фільтр.
Що таке Redis і для чого його зазвичай використовують?
Redis — сховище моделі ключ-значення, що живе в оперативній пам'яті (скидання на диск можливе, але робочий набір — у RAM), звідси його швидкість. Типові ролі: кеш перед основною, повільнішою базою, а ще сесії, лічильники, черги. Обробка команд однопотокова, тому кожна окрема команда атомарна. У ролі кешу найчастіше зустрінете патерн cache-aside: перший погляд — у Redis; якщо ключа немає (miss) — похід в основну базу, а прочитане одразу кладеться в кеш для наступних запитів. Для тестувальника Redis — додатковий шар між застосунком і базою, і саме цей шар зазвичай відповідає на питання «чому UI показує старі дані, якщо бекенд їх уже оновив».
Що таке TTL у Redis і як він пов'язаний з інвалідацією кешу?
TTL (time to live) — термін життя ключа: SET price:42 99 EX 60 означає «тримай 60 секунд», команда TTL price:42 покаже залишок, а після його вичерпання ключ зникне сам. По суті це інвалідація «за годинником», пасивна: свіжі дані гарантовано з'являться лише тоді, коли вікно TTL спливе. Усе, що має оновлюватися швидше, потребує активної інвалідації — і саме тут народжується «протухлий» (stale) кеш. Три класичні сценарії: TTL задовгий, а примусово ключ ніхто не видаляє; базу оновили, а крок «видалити/оновити ключ» у коді загубився; гонка (race condition) — паралельний запис устиг втиснутися між читанням бази й записом у кеш. Тактика QA із цього пряма: старе значення в тесті — спершу питання до кешу і його TTL, а вже потім до бізнес-логіки.
Чим серверний кеш Redis відрізняється від браузерного HTTP-кешу для тестувальника?
Шаром, у якому він живе, — і тому способом лікування. HTTP-кеш працює на клієнті, між браузером і мережею; Redis стоїть на сервері, між застосунком і його базою. Симптом у обох однаковий («бачу стару версію»), тому їх постійно плутають. Лакмус — залежність від клієнта: свіжий browser context у Playwright гарантовано обнуляє браузерний кеш, але бекендового Redis навіть не торкається — стара відповідь прилетить і в стерильний контекст, бо її віддає сам сервер. Тож якщо застаріле значення відтворюється в будь-якому браузері й на будь-якій машині, підозра переходить із клієнтського кешу на серверний — і дивитися треба TTL та інвалідацію ключів, а не чистити профіль браузера.
Що таке eventual consistency і чому «запис пройшов» не дорівнює «запис видно»?
Через реплікацію. Розподілене сховище тримає дані в кількох копіях: пише головний вузол (primary), а репліки підтягують зміни асинхронно, із запізненням — це і є лаг реплікації (replication lag). Між «primary підтвердив запис» і «репліка його показує» завжди є вікно, і читання з репліки в цьому вікні повертає старі дані. Eventual consistency якраз це й обіцяє: копії зійдуться — але «зрештою», без гарантії конкретного моменту. У тестах це класично виглядає як «запис пропав»: створили сутність, одразу читаємо — її нема, за секунду — вже є. Заводити на таке баг — помилка; правильна реакція — з'ясувати, звідки читає бекенд, і закласти відкладену видимість у дизайн тесту.
Записали дані, а тест їх одразу не бачить через реплікацію. Як правильно це тестувати?
Полінгом (polling), не паузою. Механіка: у циклі з невеликим інтервалом перевіряємо умову («запис уже видно?») і виходимо або з успіхом, або по загальному таймауту. Так тест чекає рівно стільки, скільки реально тривав лаг, — не більше й не менше. Фіксований sleep програє в обидва боки: поставиш із запасом — сюїт повзе, поставиш впритул — тест флакі, бо лаг реплікації плаває й інколи перевищує паузу. Це окремий випадок універсального принципу синхронізації в автотестах: чекати треба на настання стану, а не на годинник — так само, як з очікуванням елементів у UI чи статусів в API.
Що таке write concern і read preference у MongoDB?
Два регулятори консистентності, у яких часто «зашитий» нібито баг. Write concern керує записом: скільки вузлів кластера мають підтвердити операцію, перш ніж клієнт отримає «ОК»; з версії 5.0 типове значення — підтвердження більшістю. Read preference керує читанням: типово воно йде з primary й бачить усе записане, але його можна перемкнути на secondary, щоб розвантажити primary, — ціною ризику застарілих даних через лаг реплікації. Практичний патерн: команда перемкнула читання на secondary, і в тестах «поїхала» консистентність, хоча з погляду конфігурації все працює як задумано. Тому перед баг-репортом про неузгодженість сильний кандидат спершу дивиться саме ці два налаштування.
Розкажи про CAP-теорему.
CAP описує, чим розподілена система жертвує в момент мережевого збою. Три властивості: Consistency — будь-яке читання повертає найсвіжіший запис або чесну помилку; Availability — система відповідає на кожен запит, нехай і не найсвіжішими даними; Partition tolerance — робота не зупиняється, коли мережа між вузлами рветься. Теорему сформулював Ерік Брюер, а формально довели Гілберт і Лінч у 2002 році. Цінність для QA — не в означеннях, а в сценаріях деградації: сталася коротка партиція — і що робить система? Відхиляє запити заради узгодженості чи приймає їх, ризикуючи розбіжністю реплік? Знаючи обраний компроміс, можна відрізнити задокументовану поведінку під час збою від справжнього дефекту.
Чому «оберіть будь-які два з трьох» — неправильне трактування CAP?
Бо воно подає P як опцію, а партиції в реальній мережі — неминучість: обладнання й канали відмовляють незалежно від наших побажань. Насправді дилема вужча й ситуативна: коли партиція вже сталася, система мусить або пожертвувати доступністю заради узгодженості (CP — частина запитів дістане відмову), або лишитися доступною і змиритися з тимчасовою розбіжністю даних (AP). Поза збоєм протиставлення зникає — справна система дає й C, і A одночасно. Орієнтир: MongoDB, що читає з primary, поводиться як CP — краще відмовить, ніж віддасть неузгоджене; Cassandra в типовій конфігурації — як AP, відповідає завжди й доузгоджує потім. Тільки не варто клеїти на СУБД одну пару літер назавжди: поведінку визначає конфігурація.
Що таке PACELC і чим воно доповнює CAP?
PACELC додає до CAP другу половину — про життя без збоїв. Формула: при партиції (P) — той самий вибір A проти C, що й у CAP; інакше (Else) — вибір між затримкою (latency) і узгодженістю (consistency). Пояснення просте: сильні гарантії — це додаткові мережеві раунди (дочекатися підтвердження від більшості вузлів, сходити читанням на primary), і платити за них доводиться часом відповіді навіть у цілком здоровій системі. Тобто компроміс консистентності не вмикається лише під час аварії — він діє постійно. Для QA це готове пояснення ефекту «закрутили write concern — усе сповільнилося»: перформанс просів не через баг, а тому що система свідомо посунулася по осі latency/consistency.
Тест бачить стару ціну. Де шукати системно?
Пошук іде рівнями, від клієнта вглиб. Перший рівень — HTTP-кеш браузера: стару відповідь віддає сам клієнт; діагноз — стара версія живе лише в цьому профілі чи браузері, а свіжий контекст її не бачить. Другий — серверний кеш застосунку, типово Redis: база оновлена, але ключ не інвалідовано чи TTL ще не сплив; діагноз — стара версія однакова звідусіль, з будь-якої машини. Третій — реплікація: бекенд читає з репліки, яка ще не наздогнала primary; діагноз — розбіжність зникає сама за секунди, і ловити її треба полінгом. Сила відповіді — у порядку дій: спершу визначити, чи залежить «стара ціна» від клієнта, і лише локалізувавши рівень, лізти в логіку розрахунку. Найчастіше такий «баг ціни» виявляється кешем або відкладеною консистентністю, а не помилкою в коді продукту.
Три кейси, де NoSQL перетворює зелений тест на полювання за примарою: find, який «нічого не повертає» через тип і гнучку схему в MongoDB; запис, який видно не одразу, і полінг замість sleep; і таблиця рішень для «тест бачить стару ціну» — як за симптомом відрізнити рівень кешу. Скрізь — що дивитися і чому.
Кейс 1. MongoDB: find не бачить документ, хоча дані є
Тест перевіряє, що після реєстрації в базі з'явився користувач, і падає: findOne повертає null. У колекції запис насправді є. Дві найчастіші причини — розбіжність типів і гнучка схема.
Спершу дивимося на фактичний документ у колекції:
// те, що реально лежить у базі
{
"_id": ObjectId("665f1c2a9b1e4a0012ab34cd"),
"email": "qa@example.com",
"total": "100", // рядок, а не число
"isActive": true // старе ім'я поля, нові документи пишуть status
}
А тепер фільтр із тесту:
import { MongoClient, ObjectId } from "mongodb";
const client = await MongoClient.connect(process.env.MONGO_URI!);
const users = client.db("shop").collection("users");
// 1) РОЗБІЖНІСТЬ ТИПУ: у базі "100" (рядок), тут 100 (число) — не збіжиться
const byTotal = await users.findOne({ total: 100 });
// byTotal === null, хоча документ існує
// 2) правильно — порівнювати з тим самим типом, що в базі
const okTotal = await users.findOne({ total: "100" });
// 3) ObjectId теж не дорівнює своєму рядку
const idStr = "665f1c2a9b1e4a0012ab34cd";
const byStr = await users.findOne({ _id: idStr }); // null
const byOid = await users.findOne({ _id: new ObjectId(idStr) }); // знайде
Що дивитися і чому:
- MongoDB не приводить типи мовчки. Рядок
"100"і число100— різні значення, збіг не спрацьовує. Перш ніж заводити баг «реєстрація не створює користувача», подивітьсяtypeofполя в самому документі й у фільтрі. Часто винен не продукт, а тип у тестових даних. _id— цеObjectId, а не рядок. Порівняння з рядковим представленням даєnull. Обгортайте ідентифікатор уnew ObjectId(...)перед пошуком.- Гнучка схема ховає поле. Якщо частина коду очікує
user.status, а старі документи мають лишеisActive, читання дастьundefined— і асертexpect(doc.status).toBe("active")впаде не через баг застосунку, а через відсутню міграцію. Перевіряйте, що поле взагалі є, окремо від його значення. nullпроти відсутнього поля. Контрінтуїтивно: фільтр рівності{ phone: null }знаходить обидва стани — і явнийnull, і документи, де поля немає взагалі. Якщо тесту потрібен лише один із них, кажіть це явно:
// обидва стани: і phone: null, і документи без поля phone
const noPhone = await users.find({ phone: null }).toArray();
// лише явний null (поле є, значення порожнє)
const explicitNull = await users.find({ phone: { $type: "null" } }).toArray();
// лише документи, де поля немає взагалі
const missing = await users.find({ phone: { $exists: false } }).toArray();
Дисципліна read-only тут така сама, як для реляційних БД: із автотесту перевіряємо стан, але не псуємо чужі дані на спільному стенді.
Кейс 2. Eventual consistency: полінг замість sleep
UI-тест створює замовлення через форму, потім читає його статус через API — і зрідка падає, бо читання прилетіло раніше, ніж реплікація доїхала до репліки, з якої читає бекенд. Спокуса поставити sleep(2000) робить тест або повільним, або флакі: лаг реплікації непостійний.
Так робити не треба:
// АНТИПАТЕРН: фіксована пауза під непостійний лаг
await createOrder(page);
await page.waitForTimeout(2000); // іноді замало, іноді забагато
const res = await request.get(`/api/orders/${id}`);
expect(res.status()).toBe(200);
Правильно — полінг з таймаутом: перевіряємо умову з інтервалом, поки вона не стане істинною або не збіжить час.
import { APIRequestContext, expect } from "@playwright/test";
async function waitForOrder(
request: APIRequestContext,
id: string,
timeoutMs = 10_000,
intervalMs = 300,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await request.get(`/api/orders/${id}`);
if (res.status() === 200) return; // репліка наздогнала — виходимо
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Замовлення ${id} не стало видимим за ${timeoutMs} мс`);
}
У Playwright для UI ту саму ідею дає вбудоване очікування з ретраями — воно теж полить, а не спить:
await expect(async () => {
const res = await request.get(`/api/orders/${id}`);
expect(res.status()).toBe(200);
}).toPass({ timeout: 10_000, intervals: [300, 500, 1000] });
Що дивитися і чому:
- Лаг непостійний. Під навантаженням реплікація відстає більше, тож будь-яка фіксована пауза — або гальмо, або флак. Полінг чекає рівно стільки, скільки треба.
- Це не баг застосунку. «Запис пропав» одразу після створення — типова eventual consistency: записали на primary, прочитали з репліки до реплікації. Перед баг-репортом перевірте read preference: якщо бекенд читає з secondary, старі дані — очікувані.
- Та сама логіка всюди. «Дочекайся стану, а не спи» працює і в API-, і в UI-тестах: чекаємо на подію, а не на годинник.
Кейс 3. «Тест бачить стару ціну»: таблиця рішень за рівнями
Симптом однаковий — на екрані чи у відповіді стара ціна, хоча бекенд її оновив. Причин щонайменше три, і живуть вони на різних рівнях. Спершу локалізуй рівень за симптомом, а вже потім лізь у логіку.
| Симптом | Найімовірніший рівень | Як перевірити | Чим лікувати |
|---|---|---|---|
| Стара версія лише в одному браузері/профілі, у свіжому контексті свіжа | HTTP-кеш браузера | Чистий browser context, DevTools Network | Ізоляція контексту, вимкнення кешу |
| Стара версія однакова в будь-якому браузері й на будь-якій машині | Кеш застосунку (Redis) | TTL price:42 у Redis, чи інвалідовано ключ | Активна інвалідація ключа, розумний TTL |
| Стара версія зникає сама через кілька секунд після запису | Лаг реплікації (eventual consistency) | Полінг замість sleep, read preference | Читання з primary або полінг у тесті |
Швидка перевірка серверного кешу прямо з тесту чи консолі:
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const cached = await redis.get("price:42"); // що бекенд реально віддає з кешу
const ttl = await redis.ttl("price:42"); // скільки секунд ключ ще житиме
// якщо cached === стара ціна, а ttl великий — винна слабка інвалідація, не логіка
Що дивитися і чому:
- Спершу рівень, потім логіка. Якщо стара версія залежить від браузера — це клієнтський кеш; якщо однакова всюди — серверний кеш або реплікація. Плутати їх означає лікувати не там: чистий контекст Playwright не чіпає Redis усередині бекенда.
- Протухлий кеш — не баг ціни. Бекенд оновив базу, але ключ не інвалідовано або TTL завеликий. Це перевіряється командою
TTL, а не переглядом коду розрахунку ціни. - Зникає само за секунди — це реплікація. Тимчасова розбіжність, що вирівнюється, — ознака eventual consistency, а не втраченого запису. Ловиться полінгом, а не баг-репортом.
SQL vs NoSQL
- Знаю, що NoSQL — не «без схеми», а schema-on-read: сервер перестає відхиляти невалідні дані, а перевірка цілісності переїхала з бази в застосунок і стала роботою QA.
- Розумію різницю ACID vs BASE: строга транзакція проти «basically available, soft state, eventual consistency».
- Можу спростувати кліше «SQL надійний, NoSQL швидкий»: PostgreSQL має
JSONB, MongoDB — ACID-транзакції з 4.0, а швидкодія залежить від патерну доступу.
Типи NoSQL
- Знаю чотири моделі — документна, ключ-значення, ширококолонкова, графова — з прикладом і застосуванням кожної.
- Не зливаю Redis і MongoDB в одне: ключ-значення для кешу/сесій vs документне сховище для гнучких сутностей.
- Розумію, що перед тестуванням треба з'ясувати саме тип моделі й гарантію консистентності в цій конфігурації.
MongoDB і гнучка схема
- Знаю, що дані лежать документами (BSON) у колекціях, у кожного є
_idтипуObjectId, а запит — це фільтр-документ, не SQL. - Можу пояснити, чому
findнічого не повертає при розбіжності типів: MongoDB не приводить рядок до числа мовчки (іObjectIdне дорівнює рядку). - Розумію гнучку схему як окремий клас багів: різні поля в документах однієї колекції,
undefinedна старих даних, перейменування без міграції, а також пасткуnullпроти відсутнього поля (рівність ізnullловить обидва стани, розрізняють їх$exists/$type). - Пам'ятаю, що
$jsonSchema-валідація опційна й часто вимкнена, тож узгодженість документів перевіряю сам, дотримуючись read-only на спільних стендах.
Redis як кеш
- Розумію cache-aside (спершу кеш, за промаху — база, результат назад у кеш) і що TTL (
SET ... EX,TTL) — це «пасивна» інвалідація протягом вікна, а не миттєва. - Можу назвати три причини протухлого кешу: завеликий TTL без інвалідації, забутий крок «оновити/видалити ключ», гонка між читанням із бази й записом у кеш.
- Розрізняю серверний кеш Redis (між застосунком і базою) і браузерний HTTP-кеш: чистий
browser contextприбирає другий, але не перший.
Eventual consistency
- Можу пояснити реплікацію й eventual consistency: запис на primary, асинхронне розповзання на репліки, лаг реплікації (replication lag) — тож читання з репліки одразу після запису може бути старим, і це не баг.
- Знаю, що write concern задає, скільки вузлів підтверджують запис (з 5.0 — більшість), а read preference — звідки читати (primary vs secondary).
- Тестую видимість запису полінгом з таймаутом, а не
sleep: чекаю на подію, а не на годинник.
CAP і PACELC
- Можу розкласти CAP на C/A/P і пояснити, що P неминуча, а не опціональна.
- Розумію, що вибір C проти A виникає лише під час партиції, а на справній мережі система дає і C, і A; орієнтир — MongoDB з primary тяжіє до CP, Cassandra до AP (залежить від конфігурації).
- Можу пояснити PACELC: навіть без збою сильніша узгодженість коштує latency, тож компроміс присутній завжди.
- Вмію відрізнити три рівні «старої версії» — HTTP-кеш браузера, кеш застосунку (Redis), лаг реплікації — і не плутаю, який де лікується.
Де живе схема в типовому NoSQL-сховищі, що працює за schema-on-read?
Питання
NoSQL — це «база без схеми»? schema-on-write vs schema-on-read