Pytest — туториал

Установка Pytest

Прежде чем заняться практикой, надо установить инструмент. Как и многие пайтон-пакеты, доступен на PyPi. Можно ставить в виртуальном окружении, через pip.

В Windows PowerShell:

PS> python -m venv venv
PS> .\venv\Scripts\activate
(venv) PS> python -m pip install pytest

В Linux/MacOS:

$ python -m venv venv
$ source venv/bin/activate
(venv) $ python -m pip install pytest

Теперь все команды pytest доступны в окружении.

Чем хорош pytest

Юнит-тесты для Python обслуживает встроенный unittest, солидный по своим возможностям, но есть и недостатки, которые может решить pytest. В pytest отличная функциональность, как убедимся далее, и доступно много полезных плагинов. Стандартные задачи решаются быстро и лаконично, сложные задачи решаются плагинами. Можно убедиться в удобстве pytest, выполнив в pytest тестовые наборы, написанные в unittest.

Меньше кода

Функциональные тесты, если следовать хорошей практике, пишутся по модели Arrange-Act-Assert:

1. Arrange, настройка условий.

2. Act, действие, вызов функции (метода). 

3. Assert — проверка, что условие верно.

Тестовые фреймворки вставляют хуки в assertions, чтобы проанализировать, почему assertion падает. unittest обладает встроенными assertions. Однако, даже небольшой набор тестов создает много лишнего кода.

Например. Тестовый набор с проверкой, что unittest отработал правильно в проекте. Пишется один тест, который всегда проходит, и один тест, который всегда падает:

# test_with_unittest.py

from unittest import TestCase

class TryTesting(TestCase):
    def test_always_passes(self):
        self.assertTrue(True)

    def test_always_fails(self):
        self.assertTrue(False)

Эти тесты запускаются из командной строки с опцией discover:

(venv) $ python -m unittest discover
F.
======================================================================
FAIL: test_always_fails (test_with_unittest.TryTesting)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...\effective-python-testing-with-pytest\test_with_unittest.py",
  line 10, in test_always_fails
    self.assertTrue(False)
AssertionError: False is not true

----------------------------------------------------------------------

Ran 2 tests in 0.006s

FAILED (failures=1)

Как и ожидалось, один тест passed и один failed. Доказано, что unittest работает правильно, но много лишних действий:

  1. Импорт класса TestCase из unittest
  2. Создание подкласса TryTesting в TestCase
  3. Написание метода TryTesting для каждого теста
  4. Вызов одного из методов self.assert* из unittest.TestCase для проверок assertion-ов

Довольно много кода, причем это минимум нужный для любого теста, то есть так будет в каждом тесте. Pytest упрощает это дело, применяя обычные функции и ключевое слово в Python — assert:

# test_with_pytest.py

def test_always_passes():
    assert True

def test_always_fails():
    assert False

Не нужно особо заморачиваться с импортами и классами. Все что нужно — включить функцию в test_prefix. Поскольку применяется ключевое слово assert, не нужно помнить все методы self.assert* в unittest. Просто пишется выражение, которое должно быть True, и pytest сам проверит его.

Кроме экономии времени, pytest удобен более детализированным, легко читаемым выводом.

Удобнее вывод

Тест-сьют запускается командой из главной папки проекта:

(venv) $ pytest
============================= test session starts =============================
platform win32 -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0
rootdir: ...\effective-python-testing-with-pytest
collected 4 items

test_with_pytest.py .F                                                   [ 50%]
test_with_unittest.py F.                                                 [100%]

================================== FAILURES ===================================
______________________________ test_always_fails ______________________________

    def test_always_fails():
>       assert False
E       assert False

test_with_pytest.py:7: AssertionError
________________________ TryTesting.test_always_fails _________________________

self = <test_with_unittest.TryTesting testMethod=test_always_fails>

    def test_always_fails(self):
>       self.assertTrue(False)
E       AssertionError: False is not true

test_with_unittest.py:10: AssertionError
=========================== short test summary info ===========================
FAILED test_with_pytest.py::test_always_fails - assert False
FAILED test_with_unittest.py::TryTesting::test_always_fails - AssertionError:...

========================= 2 failed, 2 passed in 0.20s =========================

Pytest выводит результаты иначе, чем unittest, и автоматически добавляет в вывод файл test_with_unittest.py. В репорте видим:

  1. Состояние системы, а также версию Python, pytest’a, и плагинов, если они установлены
  2. Главную папку rootdir, или папку с конфигурациями и тестами
  3. Количество тестов, найденных раннером

Все это в первой секции вывода:

============================= test session starts =============================
platform win32 -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0
rootdir: ...\effective-python-testing-with-pytest
collected 4 items

Далее показывается статус каждого теста (синтаксис похож на unittest):

  • . (точка) значит, что тест прошел (passed)
  • F — не прошел (failed)
  • E, неожиданная ошибка-эксепшен

Эти символы добавляются в конец строки:

test_with_pytest.py .F                                                   [ 50%]
test_with_unittest.py F.                                                 [100%]

Если тест упал, в репорте будет детальное описание. В примере ниже тесты упали, потому что assert False всегда падает:

================================== FAILURES ===================================
______________________________ test_always_fails ______________________________

    def test_always_fails():
>       assert False
E       assert False

test_with_pytest.py:7: AssertionError
________________________ TryTesting.test_always_fails _________________________

self = <test_with_unittest.TryTesting testMethod=test_always_fails>

    def test_always_fails(self):
>       self.assertTrue(False)
E       AssertionError: False is not true

test_with_unittest.py:10: AssertionError

Такой подробный вывод чрезвычайно удобен при отладке. 

Далее, выводится общий репорт тест-сьюта:

=========================== short test summary info ===========================
FAILED test_with_pytest.py::test_always_fails - assert False
FAILED test_with_unittest.py::TryTesting::test_always_fails - AssertionError:...

========================= 2 failed, 2 passed in 0.20s =========================

Как видим, все намного лучше выглядит чем в unittest. 

Позже посмотрим, как реализовано ключевое слово assert.

Легче освоить

Скорее всего, с ключевым словом assert знаком даже джун. Если нет, далее простой пример, как работает assert:

# test_assert_examples.py

def test_uppercase():
    assert "loud noises".upper() == "LOUD NOISES"

def test_reversed():
    assert list(reversed([1, 2, 3, 4])) == [4, 3, 2, 1]

def test_some_primes():
    assert 37 in {
        num
        for num in range(2, 50)
        if not any(num % div == 0 for div in range(2, num))
    }

Как видим, это похоже на стандартные Python-функции. Поэтому кривая обучения Pytest лучше чем unittest — не нужно учить новые понятия.

Обратим внимание, что все тесты довольно-таки компактные и при этом информативные. Имена функций длинные и не требующие комментирования. Это для того, чтобы изолировать тесты друг от друга; когда что-то поломалось, сразу видно, где причина. И ее лучше видно в репорте.

Потом разберем фикстуры (fixtures) — полезнейшее средство манипуляций с тестовыми данными.

Состояния и зависимости

Тесты часто строятся на неких наборах данных («тестовых дублях» — моках и т.п.), симулирующих объекты типа словарей или JSON-файлов. 

В unittest можно выделить эти зависимости в методы .setUp() и tearDown(), чтобы все тесты в классе могли с ними работать. Это хорошо, но если тестовые классы разрастаются, зависимости в тесте превращаются в имплицитные. Иначе говоря, если смотришь на отдельный тест, не всегда видишь, что от чего зависит. 

Такие имплицитные зависимости потом выглядят как паутина из кода, в которой тяжело разобраться. Вообще, тесты должны помочь понять код приложения, а тут имеем ситуацию, когда сами тесты тяжело разобрать.

В Pytest другой подход — эксплицитные объявления зависимостей, остающихся реюзабельными благодаря фикстурам. Фикстуры в Pytest представляют собой функции, создающие данные, «тестовые дубликаты», или инициализирующие состояние системы под тестовый набор. Всякий тест, применяющий фикстуры, должен эксплицитно применять эту функцию фикстуры как аргумент для тестовой функции, чтобы зависимости всегда были видны:

# fixture_demo.py

import pytest

@pytest.fixture
def example_fixture():
    return 1

def test_with_fixture(example_fixture):
    assert example_fixture == 1

Глядя на эту функцию, сразу можно сказать, что она зависит от фикстуры, и не надо искать во всем файле описания фикстур.

Фикстуры также могут зависеть от других фикстур, объявляя их эксплицитно как зависимости. То есть, потом фикстуры могут «разбухнуть», стать «модульными» по структуре. Хотя способность вставлять фикстуры в другие фикстуры дает гибкость, потом при расширении тест-сьюта могут возникнуть проблемы с управлением зависимостями.

Простая фильтрация тестов

Когда тестовый набор разрастается, может возникнуть необходимость запустить лишь несколько тестов по какой-то из функций, и сохранить весь набор «на потом». Это можно сделать несколькими способами:

  • Фильтр по имени. Ограничить запуск только по именам, совпадающим с указанным выражением. За это отвечает параметр -k.
  • По папке. По умолчанию pytest запускает только тесты из одной папки и вложенных в ней.
  • Категории. Pytest умеет группировать тесты по категориям. Параметр -m.

Категоризация полезная вещь; можно присваивать метки-маркеры (marks) — кастомные теги любому тесту. Тест может иметь много маркеров одновременно.

Параметризация

При проверке функций обработки данных или их преобразовании, бывает много (почти) одинаковых тестов, отличающихся только вводом/выводом кода. Это дублирует код тестов, усложняет понимание поведения, которое тестируется.

Unittest дает способ сбора нескольких тестов в один, но они не показываются как отдельные в репортах. Если один тест упал, а остальные прошли, то вся группа все равно вернет один результат “упал”. В Pytest это реализовано лучше —  отдельно по каждому тесту.

Плагины

Прекрасная функция pytest — кастомизация и добавление новых функций. Любая часть pytest открыта к экспериментам. Создана хорошая экосистема плагинов.

Хотя многие плагины фокусируются на фреймворках типа Django, другие применимы к стандартным задачам. Подробнее — в разделе по плагинам в конце.

Фикстуры — управление состояниями и зависимостями

Фикстуры это способ передачи данных, «тестовых дубликатов» и настроек состояния. Это функции, возвращающие значения многих типов. Каждый тест, зависящий от фикстур, должен эксплицитно принимать эту фикстуру как аргумент.

Когда создавать фикстуры

Эмулируем типичный TDD-процесс. Например, пишем функцию format_data_for_display() для обработки данных, переданных из API-эндпойнта. Данные у нас — список людей, каждый по имени, фамилии, и должности. Функция должна вывести список строк, с полным именем сотрудника (given_name + family_name), далее через точку с запятой должность (title):

# format_data.py

def format_data_for_display(people):
    ...  # Implement this!

Если делаем все правильно по TDD, сначала надо написать тест, примерно такой:

# test_format_data.py

def test_format_data_for_display():
    people = [
        {
            "given_name": "Alfonsa",
            "family_name": "Ruiz",
            "title": "Senior Software Engineer",
        },
        {
            "given_name": "Sayid",
            "family_name": "Khan",
            "title": "Project Manager",
        },
    ]

    assert format_data_for_display(people) == [
        "Alfonsa Ruiz: Senior Software Engineer",
        "Sayid Khan: Project Manager",
    ]

Может быть нужно написать еще одну функцию преобразования данных, с разделением точкой с запятой для Excel:

# format_data.py

def format_data_for_display(people):
    ...  # Implement this!

def format_data_for_excel(people):
    ... # Implement this!

Список задач вырос. (Позитив от TDD-методики — она учит планировать.) Тест функции format_data_for_excel() будет сильно похож на функцию format_data_for_display():

# test_format_data.py

def test_format_data_for_display():
    # ...

def test_format_data_for_excel():
    people = [
        {
            "given_name": "Alfonsa",
            "family_name": "Ruiz",
            "title": "Senior Software Engineer",
        },
        {
            "given_name": "Sayid",
            "family_name": "Khan",
            "title": "Project Manager",
        },
    ]

    assert format_data_for_excel(people) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,Project Manager
"""

Заметно, что оба теста должны повторять описание переменной people, это пару строчек.

Если надо писать тесты, подтягивающие одинаковые данные, то фикстура полезна. Можно подтянуть одинаковые данные в одну функцию с декоратором @pytest.fixture, чтобы показать что эта функция представляет собой фикстуру pytest:

# test_format_data.py

import pytest

@pytest.fixture
def example_people_data():
    return [
        {
            "given_name": "Alfonsa",
            "family_name": "Ruiz",
            "title": "Senior Software Engineer",
        },
        {
            "given_name": "Sayid",
            "family_name": "Khan",
            "title": "Project Manager",
        },
    ]

# ...

Фикстуры могут применяться путем добавления ссылки на функцию как аргумент. Следует учесть, что при этом не вызывается функция. Можно применять возвращенное значение функции фикстуры как имя:

# test_format_data.py

# ...

def test_format_data_for_display(example_people_data):
    assert format_data_for_display(example_people_data) == [
        "Alfonsa Ruiz: Senior Software Engineer",
        "Sayid Khan: Project Manager",
    ]

def test_format_data_for_excel(example_people_data):
    assert format_data_for_excel(example_people_data) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,Project Manager
"""

Тест заметно похудел, но все еще указывает на данные, от которых зависит. Нужно давать именам фикстур четкие названия. Так легче будет найти их, если понадобятся в будущем.

Мощь фикстур понятна, возможно есть готовность с ними работать, но не все так однозначно.

Когда фикстуры вредны

Фикстуры хороши при экстракции данных и объектов, которые будут задействованы в нескольких тестах. Однако они не всегда работают в тестах, в которых данные меняются. Засорение тестового набора фикстурами не лучше чем засорение другими данными и объектами. Даже может быть хуже, потому что это усложнение логики. Как со многими абстракциями, фикстуры требуют опыта и внимательного подхода. В любом случае фикстуры будут применяться в тест-сьюте. И в pytest есть полезные функции, помогающие справиться с нарастающей сложностью.

Как масштабировать фикстуры

Иногда есть польза от дальнейшей абстракции. В pytest фикстуры модульные. То есть они могут импортироваться, могут импортировать другие модули, и могут зависеть от импорта других фикстур, и сами импортировать их. Все это позволяет выполнять абстракцию фикстур под конкретный use-кейс.

Например, если фикстуры в двух отдельных файлах, или в модулях, имеют общую зависимость. В таком случае можно «поднять» фикстуры из этих модулей в особый «фикстурный» модуль более высокого уровня. Затем импортировать их в любые тестовые модули, где они будут нужны. Это хороший подход, когда фикстуры есть в проекте во многих местах.

Если понадобилось «расшарить» фикстуры на весь проект и не импортировать их, для этого есть специальный конфигурационный модуль conftest.py.

Pytest ищет модуль conftest.py в каждой папке. Если добавить «общие» фикстуры в этот модуль, они будут доступны из главной папки модуля и во всех вложенных папках без импорта. Это лучшее место для часто запрашиваемых фикстур.

Еще один интересный кейс фикстур и conftest.py — контроль доступа к ресурсам. Например, написан тест-сьют для кода, работающего с API-вызовами. Нужно гарантировать, что сьют не делает реальных вызовов в сети, даже если кто-то случайно напишет тест который так делает. В Pytest есть фикстура monkeypatch для подмены значений и поведений:

# conftest.py

import pytest
import requests

@pytest.fixture(autouse=True)
def disable_network_calls(monkeypatch):
    def stunted_get():
        raise RuntimeError("Network access not allowed during testing!")
    monkeypatch.setattr(requests, "get", lambda *args, **kwargs: stunted_get())

Если положить disable_network_calls() в conftest.py и дописать опцию autouse=True, этим гарантируем, что сетевые запросы отключены во всех тестах в сьюте. Любой тест с кодом, вызывающим requests.get(), поднимет ошибку RuntimeError, сообщая о некорректном вызове подключения.

Когда тест-сьют разрастается, его выполнение начинает забирать много времени. Тогда может понадобиться ограничить запуск некими категориями тестов, категоризируя их.

Маркеры и категории

В большом тестовом наборе бывает полезно пропустить запуск всех тестов, запустив только текоторые тестируют недавно добавленную функцию. По умолчанию pytest запускает все тесты в текущей папке, или же фильтрует их, что описано выше, и еще есть функция маркеров (меток).

Маркеры обозначают категории тестов и позволяют включать/выключать запуск по категориям. Тест может иметь сколько угодно категорий.

Маркировка присваивается по подсистемам и/или зависимостям. Например, если какому-то тесту нужен доступ к базе данных, ставится метка @pytest.mark.database_access.

Примечание. Имена маркеров могут быть любыми, поэтому легко ошибиться в названиях; pytest в репорте предупреждает о маркерах, которые не распознал.

Можно поставить опцию —-strict-markers, проверки, что все метки зарегистрированы в файле конфигурации pytest.ini. Тогда тесты с незарегистрированными метками не запустятся.

Все тесты одновременно запускаются командой pytest. Когда нужно будет запустить только тесты, запрашивающие базу данных — команда pytest -m database_access. Чтобы запустить все тесты за исключением тех которым нужна эта база, ставится флажок -m “not database_access”. Можно также применить фикстуру autouse, давая доступ к базе только тестам с маркером database_access.

В некоторых плагинах есть расширенные функции маркировки. В плагине pytest-django есть маркер django_db. Все тесты, запрашивающие базу данных и не имеющие такого маркера, упадут. Первый тест, запрашивающий базу данных, создаст тестовую базу в Django.

Добавление маркера django_db заставляет объявлять зависимости эксплицитно, что соответствует философии pytest. Также это значит, что можно значительно быстрее выполнять тесты, не отправляющие запросы к БД, потому что pytest -m “not django_db” не даст тесту создавать БД. Этим экономится время, особенно если тесты часто запускаются.

В pytest есть готовые метки:

  • skip, пропустить тест в любом случае
  • skipif, пропустить по условию — если оно True
  • xfail, тест должен упасть, и если тест упадет, весь сьют сохранит статус “пройден”
  • parametrize создает несколько вариантов теста с разными аргументами

Посмотреть список предустановленных маркеров: pytest —markers.

Параметризация тестов

Ранее обсуждалось, как фикстуры режут лишний код, поднимая общие зависимости на верхний уровень. Фикстуры не всегда эффективны, если тесты с немного варьирующими входными данными и ожидаемыми результатами. Тогда нужна параметризация — создание вариантов теста с разными параметрами.

Пример. Функция проверяет, не является ли текст палиндромом (то есть, читается ли слово с конца до начала так же как наоборот, типа слово «Анна»). Начальный набор тестов выглядит примерно так:

def test_is_palindrome_empty_string():
    assert is_palindrome("")

def test_is_palindrome_single_character():
    assert is_palindrome("a")

def test_is_palindrome_mixed_casing():
    assert is_palindrome("Bob")

def test_is_palindrome_with_spaces():
    assert is_palindrome("Never odd or even")

def test_is_palindrome_with_punctuation():
    assert is_palindrome("Do geese see God?")

def test_is_palindrome_not_palindrome():
    assert not is_palindrome("abc")

def test_is_palindrome_not_quite():
    assert not is_palindrome("abab")

Тесты, кроме последних двух, выглядят примерно одинаково:

def test_is_palindrome_<in some situation>():
    assert is_palindrome("<some string>")

И это похоже на излишество. Pytest борется с этим параметризацией — @pytest.mark.parametrize() с разными значениями, экономя место:

@pytest.mark.parametrize("palindrome", [
    "",
    "a",
    "Bob",
    "Never odd or even",
    "Do geese see God?",
])
def test_is_palindrome(palindrome):
    assert is_palindrome(palindrome)

@pytest.mark.parametrize("non_palindrome", [
    "abc",
    "abab",
])
def test_is_palindrome_not_palindrome(non_palindrome):
    assert not is_palindrome(non_palindrome)

Первый аргумент в parametrize() — это строка с параметрами через запятую. Можно указывать лишь одно имя, как видим выше. Второй аргумент — список кортежей или просто значений параметров. Можно параметризацией объединить все тесты:

@pytest.mark.parametrize("maybe_palindrome, expected_result", [
    ("", True),
    ("a", True),
    ("Bob", True),
    ("Never odd or even", True),
    ("Do geese see God?", True),
    ("abc", False),
    ("abab", False),
])
def test_is_palindrome(maybe_palindrome, expected_result):
    assert is_palindrome(maybe_palindrome) == expected_result

Код сокращен, но при этом потерялись описания функций. Здесь нужно следить, чтобы параметризация не привела к тому что тестовый набор будет нечитаемым. Параметризация предназначена для «отделения тестовой логики от тестовых данных», чтобы было яснее предназначение теста.

Репорты и ускорение тестов

Иной раз при переключении контекста в IDE из кода приложения на тестовый код IDE начинает тормозить. Особенно если тесты специфические. Выше упоминалось о том, как фильтровать запуск тестов. Теперь обсудим, как ускорить тесты. Pytest фиксирует длительность выполнения и отмечает проблемные тесты.

Команда pytest запускается с ключом —durations, и в репорт добавится статистика времени выполнения. Ключ получит значение n , равное количеству самых «тормозящих» тестов. В репорте появится новая секция:

(venv) $ pytest --durations=5
...
============================= slowest 5 durations =============================
3.03s call     test_code.py::test_request_read_timeout
1.07s call     test_code.py::test_request_connection_timeout
0.57s call     test_code.py::test_database_read

(2 durations < 0.005s hidden.  Use -vv to show these durations.)
=========================== short test summary info ===========================
...

Каждый тест, попавший в эту секцию, будет кандидатом на переписывание — его время выполнения «выше среднего по набору». Более быстрые тесты по умолчанию скрыты. Можно впрочем настроить еще подробнее репорт, добавив к —durations ключ -vv.

Следует учитывать, что некоторые тесты могут иметь особенности и тормозить для них — нормально. Например, ранее упомянутый тест с маркером django_db создает тестовую Django-базу; репорт по времени выполнения учитывает и время на настройку этой базы, поэтому такой тест будет ошибочно зачислен в «тормозящие».

Далее рассмотрим полезные плагины.

Плагины для pytest

Pytest-randomly

Если тестовый набор небольшой, порядок выполнения в нем не очень важен, но если кодовая база выросла, выполнение тестов в неправильном порядке нежелательно и может вызвать проблемы.

Есть плагин pytest-randomly, который запускает тесты в случайном (рендомном) порядке. Сам pytest всегда выстраивает тесты по порядку перед их запуском; pytest-randomly просто “перемешивает” их.

Это делается для того чтобы найти тесты, зависящие от порядка их запуска, то есть «зависящие от состояния» (stateful dependency). Такое редко бывает, если сьют создан с нуля в pytest; обычно бывает по другому — сьют передан в pytest из другого фреймворка.

Плагин добавит команду рандомизации в файл конфигурации. Это число потом будет вызвано для запуска тестов в том же порядке, чтобы воспроизвести баг.

Сайт

pytest-cov

Если нужно померять, как хорошо тесты покрыли код имплементации, обычно берут пакет coverage. Pytest-cov интегрирует в окружение пакет coverage, так что можно запустить pytest —cov и посмотреть репорт по покрытию.

Сайт

Pytest-django

Существует много фикстур и маркеров для тестов в Django. Выше ознакомились с django_db. Например фикстура rf дает прямой доступ к экземпляру RequestFactory. Фикстура settings — быстрый способ оверрайдить настройки Django. Эти плагины улучшают продуктивность.

Сайт

Pytest-bdd

Pytest можно применять для обычных тестов, не только модульных, а и например BDD. BDD — простое описание (на Simple English) действий пользователя и ожидаемых результатов, по которых затем оценивают, внедрять ли новые функции. Pytest-bdd позволяет писать тесты функций для Gherkin.

Сайт

Источник туториала

***

Проджект- и продакт-менеджмент на канале

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас