атомарный релиз что такое
Приёмы неблокирующего программирования: атомарные операции и частичные барьеры памяти
В первой статье цикла мы познакомились с простыми неблокирующими алгоритмами, а также рассмотрели отношение “happens before”, позволяющее их формализовать. Следующим шагом мы рассмотрим понятие «гонки данных» (data race), а также примитивы, которые позволяют избежать гонок данных. После этого познакомимся с атомарными примитивами, барьерами памяти, а также их использованием в механизме “seqcount”.
С барьерами памяти некоторые разработчики ядра Linux уже давно знакомы. Первый документ, содержащий что-то похожее на спецификацию гарантий, предоставляемых ядром при одновременном доступе к памяти — он так и называется: memory-barriers.txt. В этом файле описывается целый зоопарк барьеров вместе с ожидаемым поведением многопоточного кода в ядре. Также там описывается понятие «парных барьеров» (barrier pairing), что похоже на пары release-acquire операций и тоже помогает упорядочивать работу потоков.
В этой статье мы не будем закапываться так же глубоко, как memory-barriers.txt. Вместо этого мы сравним барьеры с моделью acquire и release-операций и рассмотрим, как они упрощают (или, можно сказать, делают возможной) реализацию примитива “seqcount”. К сожалению, даже если ограничиться лишь наиболее популярными применениями барьеров — это слишком обширная тема, поэтому о полных барьерах памяти мы поговорим в следующий раз.
Гонки данных и атомарные операции
Рассматриваемое здесь определение гонок данных впервые сформулировано в C++11 и с тех пор используется многими другими языками, в частности, C11 и Rust. Все эти языки довольно строго относятся к совместному доступу к данным без использования мьютексов: так позволяется делать только со специальными атомарными типами данных, используя атомарное чтение и атомарную запись в память.
Гонка данных возникает между двумя операциями доступа к памяти, если 1) они происходят одновременно (то есть, не упорядочены отношением «A происходит перед B»), 2) одна из этих операций — это запись, и 3) хотя бы одна из операций не является атомарной. В результате гонки данных (с точки зрения C11/C++11) может произойти что угодно — неопределённое поведение. Отсутствие гонок данных ещё не означает невозможность «состояний гонки» (race conditions) в алгортимах: гонка данных — это нарушение стандарта языка, а состояние гонки — это ошибка в реализации алгортима, вызванная неправильным использованием мьютексов, acquire-release семантики, или и того и другого.
Избежать гонок данных и вызываемого ими неопределённого поведения очень легко. Самый простой способ — это работать с данными только из одного потока. Если данные должны быть доступны другим потокам, то работа с ними должна быть упорядочена нужным вам образом с помощью acquire и release-операций. Наконец, вы можете воспользоваться атомарными операциями.
C11, C++11, Rust предоставляют целый спектр атомарных операций доступа к памяти, гарантирующих некоторый порядок доступа (memory ordering). Нас интересуют три вида: acquire (для чтения), release (для записи), и relaxed (для того и другого). Что делают acquire и release вам уже должно быть ясно, в ядре Linux это называется smp_load_acquire() и smp_store_release(). А вот relaxed-операции обеспечивают так называемый «нестрогий» порядок доступа к памяти. Нестрогие операции не создают никаких отношений порядка между потоками. Их единственная задача — это предотвратить гонку данных и избежать нарушения строгой буквы стандарта.
На практике, Linux ожидает от компиляторов и процессоров больше гарантий, чем того требуют стандарты языков программирования. В частности, в ядре считается, что обычные, неатомарные операции доступа к памяти не приводят к неопределённому поведению лишь потому, что где-то там другой процессор трогает тот же участок памяти. Нет, естественно, при гонке данных считанное или записанное значение может быть неопределённым и содержать что попало. Например, половинку старого и половинку нового значения; и если это указатель, то разыменовывать его не стоит. Но от гонки данных как таковой компьютер не взрывается.
Кроме того, компиляторы порой очень изобретательны при оптимизациях и им очень многое позволяется делать, при условии сохранении наблюдаемого поведения в одном потоке (даже ценой неожиданного поведения во всех остальных потоках). Так что идея нестрого упорядоченного — но строго атомарного — доступа к памяти оказывается полезна и в ядре Linux, где для этих целей существуют макросы READ_ONCE() и WRITE_ONCE(). Считается хорошим стилем использовать READ_ONCE() и WRITE_ONCE(), когда вам нужны явные операции с памятью, что мы отныне и будем делать в примерах кода.
Эти макросы уже встречались в первой части:
Они похожи на smp_load_acquire() и smp_store_release(), но первый аргумент тут является lvalue, а не указателем (внимание на присваивание message в потоке 1). При отсутствии иных механизмов, избегающих гонок данных (вроде захваченного спинлока), настоятельно рекомендуется использовать READ_ONCE() и WRITE_ONCE() при обращении к данным, доступным другим потокам. Сами эти операции не упорядочены, но всегда используются вместе с чем-то ещё, вроде другого примитива или механизма синхронизации, который уже обладает release и acquire семантикой. Так атомарные операции оказываются в итоге упорядочены нужным образом.
Пусть, например, у вас есть struct work_struct, которые в фоне затирают ненужные массивы единичками. После запуска задачи у вас есть другие важные дела и массив вам не нужен. Когда понадобится, то вы делаете flush_work() и гарантированно получаете единички. flush_work(), как и pthread_join(), обладает acquire-семантикой и синхронизируется с завершением struct work_struct. Поэтому читать из массива можно и обычными операциями чтения, которые гарантированно произойдут после записи, выполненной задачей. Однако, если вы забиваете единичками регионы, которые могут пересекаться и обновляться из нескольких потоков, то им следует использовать WRITE_ONCE(a[x], 1), а не просто a[x] = 1.
Всё становится сложнее, если release и acquire-семантика обеспечивается барьерами памяти. Рассмотрим в качестве примера реальный механизм “seqcount”.
Seqcounts
Seqcount (sequence counter) — это специализированный примитив, сообщающий вам, а не изменилась ли структура данных, пока вы с ней работали. У seqcounts довольно узкая зона применимости, где они показывают себя хорошо: защищаемых ими данных должно быть немного, при чтении не должно быть никаких побочных эффектов, а записи должны быть сравнительно быстрыми и редкими. Но зато читатели никогда не блокируют писателей и никак не влияют на их кеш. Эти довольно существенные преимущества, когда вам нужна масштабируемость.
Seqcount работает с одним писателем и множеством читателей. Обычно seqcount комбинируется с мьютексом или спинлоком для того, чтобы гарантировать эксклюзивный доступ писателям; в результате получается примитив seqlock_t, как его называют в Linux. Вне ядра слова seqlock и seqcount порой используются как синонимы.
Фактически, seqcount — это счётчик поколений. Нечётный номер поколения означает, что в этот момент времени со структурой данных работает писатель. Если читатель увидел нечётный номер на входе в критическую секцию или если номер изменился на выходе из критической секции, то структура данных возможно изменялась. Читатель мог увидеть лишь несогласованную часть этих изменений, так что ему следует повторить всю свою работу с начала. Для корректного функционирования seqcount читатель должен корректно опознавать начало и конец работы писателя. По паре load-acquire и store-release операций на каждую сторону. Если раскрыть все макросы, то простая [и неправильная] реализация seqcount на уровне отдельных операций с памятью выглядит примерно так:
Этот код слегка похож на «передачу сообщений» из первой части. Здесь видно две пары load-acquire — store-release операций: для sc и для data.x. И довольно легко показать, что они обе необходимы:
Вопрос на самопроверку: зачем читатель делает «&
Но если внимательно присмотреться и подумать*, то в коде есть хитрая ошибка! Так как писатель не делает ни одной acquire-операции, то присваивание data.y в принципе может произойти ещё до первого инкремента sc. Конечно, можно психануть и делать вообще всё исключительно через load-acquire/store-release, но это пальба из пушки по воробьям и только маскирует проблему. Если подумать ещё чуть-чуть, то можно найти правильное и эффективное решение.
________
* Вот я, например, сразу не заметил этой ошибки и мне уже в комментариях подсказали.
В первой статье мы видели, что порой в Linux используют WRITE_ONCE() и smp_wmb() вместо smp_store_release(). Аналогично, smp_rmb() и READ_ONCE() — вместо smp_load_acquire(). Эти частичные барьеры памяти создают особый тип отношений порядка между потоками. А именно, smp_wmb() делает все последующие неупорядоченные присваивания release-операциями, а smp_rmb(), соответственно, превращает предыдущие неупорядоченные чтения в load-acquire. (Строго говоря, это не совсем так, но примерно так о них можно думать.)
Попробуем улучшить работу с полями data:
Даже если не знать семантики smp_wmb() и smp_rmb(), любому программисту очевидно, что такой код гораздо проще завернуть в удобный API. С данными можно работать, используя обычные атомарные операции (а модель памяти Linux даже позволяет и неатомарные), тогда как волшебные барьеры можно спрятать за read_seqcount_retry() и write_seqcount_begin().
Добавленные барьеры разделяют READ_ONCE() и WRITE_ONCE() на группы, обеспечивая безопасность работы seqcount. Но тут есть пара нюансов:
В случае seqcount порядок между чтениями и записями не особо важен, так как читатель никогда не пишет в общую память. В потоке писателя же операции очевидно упорядочены как надо. Логика тут слегка неочевидная — размышляйте, пока не дойдёт. Пока код остаётся таким же простым, как и в примере, то он будет работать. Если читателю всё же потребуется изменять память, то для этих присваиваний потребуется уже что-то другое, не seqcount.
Предыдущий абзац очень неформально всё описывает, но это хорошая иллюстрация того, почему важно знать паттерны неблокирующего программирования. Это знание позволяет размышлять о коде на более высоком уровне без потери точности. Вместо того, чтобы описывать каждую инструкцию отдельно, вы можете просто сказать: «data.x и data.y защищены seqcount sc». Или как в предыдущем примере: «a передаётся другому потоку через message». Мастерство неблокирующего программирования отчасти состоит в умении узнавать и использовать подобные паттерны, облегчающие понимание кода.
На этом, пожалуй, пока и остановимся. Естественно, тема барьеров далеко не исчерпана, поэтому в следующей статье мы рассмотрим полные барьеры памяти, как барьеры вообще работают, и ещё больше примеров их использования в ядре Linux.
Атомарные и неатомарные операции
Перевод статьи Джефа Прешинга Atomic vs. Non-Atomic Operations. Оригинальная статья: http://preshing.com/20130618/atomic-vs-non-atomic-operations/
В Сети уже очень много написано об атомарных операциях, но в основном авторы рассматривают операции чтения-модификации-записи. Однако, существуют и другие атомарные операции, например, атомарные операции загрузки (load) и сохранения (store), которые не менее важны. В этой статье я сравню атомарные загрузки и сохранения с их неатомарными аналогами на уровне процессора и компилятора C/C++. По ходу статьи мы также разберемся с концепцией «состояния гонок» с точки зрения стандарта C++11.
Операция в общей области памяти называется атомарной, если она завершается в один шаг относительно других потоков, имеющих доступ к этой памяти. Во время выполнения такой операции над переменной, ни один поток не может наблюдать изменение наполовину завершенным. Атомарная загрузка гарантирует, что переменная будет загружена целиком в один момент времени. Неатомарные операции не дают такой гарантии.
Без подобных гарантии неблокирующее программирование было бы невозможно, поскольку было бы нельзя разрешить нескольким потокам оперировать одновременно одной переменной. Мы можем сформулировать правило:
В любой момент времени когда два потока одновременно оперируют общей переменной, и один из них производит запись, оба потока обязаны использовать атомарные операции.
Если вы нарушаете это правило, и каждый поток использует неатомарные операции, вы оказываетесь в ситауции, которую стандарт C++11 называет состояние гонок по данным (data race) (не путайте с похожей концепцией из Java, или более общим понятием состояния гонок (race condition)). Стандарт C++11 не объясняет, почему состояние гонок плохо, однако утверждает, что в таком состоянии вы получите неопределенное поведение (§1.10.21). Причина опасности таких состояний гонок, однако, очень проста: в них операции чтения и записи разорваны (torn read/write).
Операция с памятью может быть неатомарной даже на одноядерном процессоре только потому, что она использует несколько инструкций процессора. Однако и одна инструкция процессора на некоторых платформах также может быть неатомарной. Поэтому, если вы пишите переносимый код для другой платформы, вы никак не можете опираться на предположение об атомарности отдельной инструкции. Давайте рассмотрим несколько примеров.
Неатомарные операции из нескольких инструкций
Допустим, у нас есть 64-битная глобальная переменная, инициализированная нулем.
В какой-то момент времени мы присвоим ей значение:
Если мы скомпилируем этот код с помощью 32-битного компилятора GCC, мы получим такой машинный код:
Видно, что компилятор реализовал 64-битное присваивание с помощью двух процессорных инструкций. Первая инструкция присваивае нижним 32 битам значение 0x00000002, и вторая заносит в верхние биты значение 0x00000001. Очевидно, что такое присваивание неатомарно. Если к переменной sharedValue одновременно пытаются получить доступ различные потоки, можно получить несколько ошибочных ситуаций:
Параллельное чтение из sharedVariable также имеет свои проблемы:
Здесь таким же образом компилятор реализует чтение двумя инструкциями: сначала нижние 32 бита считываются в регистр EAX, а потом верхние 32 бита считываются в EDX. В этом случае, если параллельная запись будет произведена между этими двумя инструкциями, мы получим разорванную операцию считывания, даже если запись была атомарной.
Эти проблемы отнюдь не теоретические. Тесты библиотеки Mintomic включает тест test_load_store_64_fail, в котором один поток сохраняет набор 64-битных значений в переменную используя обычный оператор присваивания, а другой поток производит обычную загрузку из той же самой переменной, проверяя результат каждой операции. В многопоточном режиме x86 этот тест ожидаемо падает.
Неатомарные инструкции процессора
Операция с памятью может быть неатомарной даже если она выполняется одной инструкцией процессора. Например, в наборе инструкций ARMv7 есть инструкция strd, которая сохраняет содержимое двух 32-битных регистров в 64-битной переменной в памяти.
На некоторых ARMv7 процессорах эта инструкция не является атомарной. Когда процессор видит такую инструкцию, он на самом деле выполняет две отдельные операции (§A3.5.3). Как и в предыдущем примере, другой поток, выполняющийся на другом ядре, может попасть в ситуацию разорванной записи. Интересно, что ситуация разорванной записи может возникнуть и на одном ядре: системное прерывание — скажем, для запланированной смены контекста потока — может возникнуть между внутренними операциями 32-битного сохранения! В этом случае, когда поток возобновит свою работу, он начнет выполнять инструкцию strd заново.
Другой пример, всем известная операция архитектуры x86, 32-битная операция mov атомарна в том случае, когда операнд в памяти выровнен, и не атомарна в противном случае. То есть, атомарность гарантируется только в случае, когда 32-битное целое число находится по адресу, который делится на 4. Mintimoc содержит тестовый пример test_load_store_32_fail, который проверяет это условие. Этот тест всегда выполняется успешно на x86, но если его модифицировать так, чтобы переменная sharedInt находилась по невыровненному адресу, тест упадет. На моем Core 2 Quad 6600 тест падает, когда sharedInt разделен между различными линиями кеша:
Думаю, мы рассмотрели достаточно нюансов процессорного выполнения. Давайте взглянем на атомарность на уровне C/C++.
Все операции C/C++ считаются неатомарными
В C/C++ каждая операция считается неатомарной до тех пор, пока другое не будет явно указано прозводителем компилятора или аппаратной платформы — даже обычное 32-битное присваивание.
Стандарты языка ничего не говорят по поводу атомарности в этом случае. Возможно, целочисленное присваивание атомарно, может быть нет. Поскольку неатомарные операции не дают никаких гарантий, обычное целочисленное присваивание в C является неатомарным по определению.
На практике мы обычно обладаем некоторой информацией о платформах, для которых создается код. Например, мы обычно знаем, что на всех современных процессорах x86, x64, Itanium, SPARC, ARM и PowerPC обычное 32-битное присваивание атомарно в том случае, если переменная назначения выровнена. В этом можно убедиться, перечитав соответствующий раздел документации процессора и/или компилятора. Я могу сказать, что в игровой индустрии атомарность очень многих 32-битных присваиваний гарантируется этим конкретным свойством.
Как бы там ни было, при написании действительно переносимого кода C и C++, мы следуем давно установившейся традиции считать, что мы не знаем ничего более того, что нам говорят стандарты языка. Переносимые C и C++ спроектированы так, чтобы выполнятся на любом возможном вычислительном устройстве прошлого, настоящего и будущего. Я, например, люблю представлять устройство, память которого можно менять только предварительно заполнив ее случайным мусором:
На таком устройстве вы уж точно не захотите произвести параллальное считывание, так же как и обычное присваивание, потому что слишком высок риск получить в результате случайное значение.
В C++11 наконец-то появился способ выполнять действительно переносимые атомарные сохранения и загрузки. Эти операции, произведенные с помощью атомарной библиотеки C++11 будут работать даже на условном устройстве, описанном ранее: даже если это будет означать, что библиотеке прийдется блокировать мьютекс для того, чтобы сделать каждую операцию атомарной. Моя библиотека Mintomic которую я выпустил недавно, не поддерживает такое количество различных платформ, но работает на некоторых старых компьютерах, оптимизирована вручную и гарантировано неблокирующая.
Расслабленные (relaxed) атомарные операции
Давайте вернемся к примеру с sharedValuem который мы рассматривали в начале. Давайте перепишем его с использованием Mintomic так, чтобы все операции выполнялись атомарно на каждой платформе, которую поддерживает Mintomic. Для начала мы объявим sharedValue как один из атомарных типов Mintomic:
Тип mint_atomic64_t гарантирует корректное выравнивание в памяти для атомарного доступа на каждой платформе. Это важно, поскольку, например, компилятор gcc 4.2 для ARM в среде разработки Xcode 3.2.5 не гарантирует, что тип uint64_t будет выровнен на 8 байтов.
В функции storeValue вместо выполнения обычного неатомарного присваивания, мы должны выполнить mint_store_64_relaxed.
Аналогично, в loadValue мы вызываем mint_load_64_relaxed.
Если использовать терминологию C++11, то эти функции сейчас свободны от состояний гонок по данным (data race free). Если они будут вызваны одновременно, абсолютно невозможно оказаться в ситуации разорванного чтения или записи, независимо от того, на какой платформе выполняется код: ARMv6/ARMv7(режимы Thumb или ARM), x86, x64 или PowerPC. Если вам интересно как работают mint_load_64_relaxed и mint_store_64_relaxed, то обе функции используют инструкцию cmpxchg8b на платформе x86. Подробности реализации для других платформ можно найти в реализации Mintomic.
Вот такой же код с использованием стандартной библиотеки C++11:
Вы должны были заметить, что оба примера используют расслабленные атомарные операции, что подтверждается суффиксом _relaxed в идентификаторах. Этот суффикс напоминает об определенных гарантиях относительно упорядочивания памяти (memory ordering).
В частности, для таких операций допукается переупорядочивание операций с памятью в соответствии с переупорядочиванием компилятором либо с переупорядочиванием памяти процессором. Компилятор даже может оптимизировать избыточные атомарные операции, так же как и неатомарные. Но во всех этих случаях атомарность оперций сохраняется.
Я думаю, что в случае выполнения параллельных операций с памятью, использование функций атомарных библиотек Mintomic или C++11 является хорошей практикой, даже если вы уверены, что обычные операции чтения либо записи будут атомарны на спользуемой вами платформе. Использование атомарных библиотек будет служить лишним напоминанием, что переменные могут быть использованы в конкурентной среде.
Надеюсь, теперь вам стало понятнее, почему Самая простая в мире неблокирующая хэш-таблица использует Mintomic для манипуляции общей памятью одновременно с другими потоками.
Об авторе. Джефф Прешинг работает архитектором ПО в игровой компании Ubisoft и специализируется на многопоточном программировании и неблокирующих алгоритмах. В этом году он делал доклад о многопоточной разработке игр в соответствии со стандартом С++11 на конференции CppCon, видео этого доклада было и на Хабре. Он ведет интересный блог Preshing on Programming, посвященный в том числе и тонкостям неблокирующего программирования и связанных с ним нюансов C++.
Я бы хотел много статей из его блога перевести для сообщества, но поскольку его записи часто ссылаются одна на другую, выбрать статью для первого перевода достаточно сложно. Я попытался выбрать такую статью, которая бы минимально базировалась на других. Хотя рассматриваемый вопрос достаточно прост, я надеюсь, он все же будет интересен многим, кто начинает знакомиться с многопоточным программированием в C++.
Атомарность в тестировании для новичков
(Время чтения — 6 минут)
Привет всем читателям этой статьи! Меня зовут Кирилл. Я помогаю в обучении студентов тестированию, а так же веду свой телеграм-канал @aboutqa (можете подписаться, если интересно). Через меня проходит много новичков в IT, желающих познать тайны этой профессии — профессии QA специалиста. И я заметил, что они часто путаются в понимании принципа тест-дизайна — атомарности тестов. Того, что матёрые тестировщики называют: «Один тест — одна проверка».
Опытным тестировщикам эта статья может не зайти, поэтому не удивляйтесь, что для вас тут кругом будут капитаны Очевидность. Поверьте моему педагогическому опыту — новичкам это вообще не очевидно!
Итак. Откуда вообще ноги растут?
Давайте ответим на следующие вопросы:
Ради чего вообще придумали тест-дизайн? Какие цели мы преследуем? И почему многие называют тест-дизайн важнейшим навыком тестировщика?
Сложность тест-дизайна не в самой методологии, а в большой ситуативности её применения. При тест-дизайне надо отталкиваться от здравого смысла и текущих условий на проекте, баланса формализованности проверок и доступных временных и человеческих ресурсов.
При написании тест-кейсов надо учитывать цель написания этих самых кейсов (спасибо кэп). Ответьте для себя на следующие вопросы.
Почему именно кейсы, а не чек-лист? Потому что заказчик требует, чтобы на проекте были кейсы; потому что кейсы будут выполнять роль отсутствующей/неполной документации; потому что проходить кейсы будут неопытные студенты и т.п.
Что вам в итоге этими кейсами надо проверить? Функциональность, логику, «формочки», интеграционные проверки или все вместе.
Кто будет пользоваться этими тест-кейсами? Новичок мало-понимающий в тестировании; коллега из саппорта; опытный профессионал, знающий продукт; автоматизатор; заказчики во время приемочного тестирования.
Большой ли и долгосрочный ли проект? На огромных проектах если расписывать тест-кейсы подробно, их будет очень-очень много, в то же время детальные тесты являются хорошей инвестицией в будущее для длительных проектов; в каких-то маленьких проектах возможно имеет смысл расписывать отдельно каждую проверку, чтобы убедиться, что проверили всё от и до.
Какая область деятельности компании и цена ошибки? Если вы делаете приложение для общения, обмена фоточек с котиками и монетизируетесь за счет рекламы — это одно; а если вы работаете в медицине, банковской сфере или тяжелой промышленности — это совершенно другое.
При написании тест-кейсов надо учитывать цель написания этих самых кейсов.
Как это ни парадоксально, но атомарные проверки (одна из техник тест-дизайна) позволяет уменьшить кол-во тестов.
Атомарное условие — это такое условие, которое нельзя более декомпозировать на более мелкие условия. И хотя умение декомпозировать функциональность и условия работы ПО — это практически предмет целого небольшого семинара, тем не менее основная суть вот в чем. Попробую объяснить на примере.
Мы всегда тестируем в неком наборе конфигураций. Предположим мы проверяем загрузку контакта клиента в нашу систему. Пусть у клиента четыре поля:
Первым делом мы проверим положительный и самый близкий к жизни вариант.
Атомарное условие — это такое условие, которое нельзя более декомпозировать на более мелкие условия.
Иван Иванов 12.10.1988 +79123456789, который покажет нам принципиальную работоспособность программы. Дальше мы будем менять одно поле, оставляя другие принципиально правильными.
Зачем это сделано? Всё просто — это элементарная локализация. Похоже на то, как мы исследуем потенциальный дефект, только превентивно. В примере выше мы получили 5 проверок (1 супер-позитивная и 4 вариации по числу параметров).
Мы могли бы сделать два теста:
Но тогда, в случае если второй тест упадёт — мы так и не поймем какое именно поле вызвало ошибку. На самом деле кол-во тестов значительно больше за счет вариативности самих параметров. Например «имя» и «фамилия» надо протестировать на пустое значение, спецсимволы, чувствительность к регистру и прочему. Телефон и дата рождения имеют свой специфичный набор классических негативных проверок. Так что тестов получается очень дофига (для нашего примера
Зачем нужна атомарность проверок, думаю понятно. Отсюда же и понятно, почему каждый уважающий себя наставник, тренер и просто руководитель тестирования «кричит» о том, что тесты должны быть атомарны. Но проблема вот в чем. Я начал с того, что многие начинающие тестировщики (кандидаты в джуны) путают смысл атомарности с. (не знаю с чем) с желанием сделать как можно больше тестов. Давайте снова вернемся к нашему примеру.
Мы обсудили только входные данные. Но как правило тестирование подразумевает сравнение фактического результата (ФР) с ожидаемым результатом (ОР). Вот ввели мы в нашу форму тестовые данные. А дальше что? Дальше, допустим, нам нужно зайти в систему, проверить, что создался новый клиент. Затем зайти в карточку клиента и посмотреть там в пяти местах. Потом мы идём в смежную систему и проверяем, как данные осели в базе данных. Может еще дёргаем какую-нибудь API между делом. И вот многие (я сейчас на полном серьезе, не кидайте в меня помидорами) создают 6+ тестов, которые делают одно и то же (то есть сценарий тестирования-то один), но «проверки», то есть валидация результатов разная. И вот у нас и так было
720 тестов из-за комбинаторики и атомарности, теперь их стало в 6 раз больше.
Какой вывод можно вывести из этого? Атомарность тестов — это про проверку условий. Про декомпозицию и перебор входных воздействий. Но ни в коем случае не про результат.
В жизни многие на это забивают.
Начну с того, что, конечно, 720 ручных тестов одной формочки редко кто делает. Решают проблему методикой Pairwise (алгоритм all pairs) и вместо 720 тестов вот у нас уже всего 36 (да, эта черная магия работает именно так). В интернете много интересных видео про pairwise, не буду останавливаться на этом.
Даже имея 36 тестов просто на форму, жизнь вносит немного коррективов. Вот, например, надо вам проверить функционал покупки в онлайн-магазине. Он состоит из нескольких крупных шагов: добавлении в корзину товара, логина в систему, оформления покупки.
По идее, если тест не пройдет — нам будет трудно понять в чем причина: у нас добавление товара не работает, логин или оформление покупки (собственно то, что мы проверяем). Логично создать отдельный тест на логин. Убедиться, что логин работает. Потом на работу с корзиной (добавление, удаление), а потом оформление покупки.
Так и происходит. Просто мы делаем не по одному тесту, а целые тестовые наборы — много тестов на логин (в т.ч. позитивные), много тестов для работы с корзиной, а после, фиксируя позитивные элементы двух шагов мы перебираем разные вариации оформления покупки. Именно поэтому эти тест-кейсы объединяются в тест-сьют «Оформление покупки», хотя по сути своей, они косвенно проверяют и логин и добавление товаров в корзину. Если мы будем выполнять тесты непоследовательно, мы не заметим проблемы с логином. Но если мы будем спускаться по логике работы приложения, мы сможем выявлять проблемы намного раньше.
Как-то уж очень жестоко получается, да? 100500 проверок для какого-то кусочка функционала.Только представьте: 36 тестов на логин, 15 тестов на корзину и еще 50 на оформление товара. Может ну их атомарные проверки?
И да, иногда «ну их» — это рабочая стратегия.
Если скорость важнее качества, то многие проверки объединяют. Обычно так делают, когда проект уже устоявшийся, всё проверено было раз 100 и вероятно 101й прогон атомарных тестов не выявит проблем. Тогда негативные проверки объединяют. И вот если уже какая-то комбо-проверка вызовет ошибку — начнут разворачивать этот «тест» в последовательность атомарных тестов.
Если скорость важнее качества, то многие проверки объединяют
Добавлю, что смысл атомарных проверок крайне актуален в автоматизации. Когда мы хотим из отчета об автоматизации в случае падения теста понять что произошло. Согласитесь, что падения атомарного теста с именем digitsInSurname (цифры в фамилии) намного понятнее, чем общего теста wrongClientData (неправильные данные клиента). Именно поэтому умение декомпозировать проверки важно для автоматизаторов тестирования.
Затронув тему автоматизации мы подходим к еще одному боевому рубежу. Он называется «Один тест — один assert». То есть переводя на язык ручного тестировщика — «Один тест — один ожидаемый результат».
Но я же выше говорил, что плохо повторять одни и те же тесты, если можно выполнить один сценарий и в рамках его проверить в 5 местах данные. А тут получается, что нужно работать не так? Не совсем. Смотрите, многие раздвигают понятие атомарности ручного тестирования в автоматизации до трёх условий:
1. один тест — одна логическая проверка
2. работа тестов не зависит от порядка их запуска
3. до начала и после завершения теста система находиться в одинаковом состоянии
Это правило связано с особенностями прохождения тестов программой. Если программа выявляет ошибку на первом из 5 ассертов (то есть сравнений ОР и ФР), то 4 других проверены не будут и возможные баги будут скрыты. Это основная проблема автоматизации. Однако я всё равно придерживаюсь мнения о том, что лучше в один сценарий (тест-кейс) добавить несколько проверок-сравнений, чем гонять однотипные тесты для разных проверок.
Что до атомарности. За красивой фразой «Один тест — одна проверка» скрывается факт, что в жизни мало кто пользуется этим правилом. Однако, даже полностью атомарные тесты не преследуют своей целью наплодить миллионы однотипных проверок.
Автоматизаторы должны больше внимания уделять понятию атомарности, т.к. скорость выполнения и легкость покрытия тестовыми данными позволяет развязать им руки. В то же время атомарность автотестов позволяет предоставить точный и детальный отчет об автоматизации.
Как обычно. Хотел вбросить немножко про то, что атомарность, это не дробление тестов по принципу «чем больше, тем лучше», а в итоге написал целый трактат.