Василий Половнёв

Ведущий разработчик.

Специализация — Рельсы, фронтенд, автоматизация тестирования и разработки.

Связаться: vasily@polovnyov.ru или телеграм.

Блог Курс о тестах в Руби и Рельсах

Большая маленькая ложь

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

Если у задачи нет дедлайна, считайте, что и задачи нет. Поэтому любое «сделаем когда-нибудь» нужно либо фиксировать дедлайном, либо отпускать с миром: значит, не очень-то оно нам и нужно.

P. S. Даже если добавить «после пуска», «после майских» или «после отпусков», ситуация не изменится: никто ничего не сделает, все забудут.

Карма тестировщика

Бывают люди с кармой тестировщика: что бы они не использовали, находят самые невероятные баги, заусенцы и нестыковки. Взялся пинговать ya.ru, сломал ping ¯\_(ツ)\_/¯. Самые суровые из них одарены еще и бесконечной энергией: 5 багрепортов за 10 минут для них не проблема.

Конечно, когда я был начинающим программистом, я всегда их недолюбливал: ну емана, неужели тяжело НОРМАЛЬНО пинговать? Зачем это исправлять, почему нельзя последовательно нажать A, B и C, но с минимальной задержкой в 300 мс?

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

irb: echo on assignment

Недавно заметил, что при запуске рельсовой консоли пропало «эхо» при присвоении. Раньше было так:

> subscription = Subscription.find(123)
=>
#<Subscription:0x00007fe17d6b6e38
 id: 123,
 product_id: "xxx",
 status: "active">

А стало так (некруто):

> subscription = Subscription.find(123)
=>
#<Subscription:0x00007fb3f4d06588
...

Полез разбираться и узнал пару интересных вещей. Во-первых, с марта railties бандлит irb.

Это нужно для того, чтобы ребята со старым Руби все равно получили новый irb вместо «системного» старья.

Во-вторых, новый irb по умолчанию обрезает «эхо» при присвоении:

В-третьих, чтобы вернуть «эхо», достаточно подкрутить конфигурацию irb в ~/.irbrc:

IRB.conf[:ECHO_ON_ASSIGNMENT] = true

Там же можно вырубить всратое автодополнение:

IRB.conf[:USE_AUTOCOMPLETE] = false

Как тестировать код, завязанный на рандом

Скажем, есть у нас банерокрутилка, в которой банеры показываются с заданной вероятностью:

class Banner
  attr_reader :probability

  def initialize(probability:)
    @probability = probability
  end

  def visible?
    probability > Kernel.rand
  end
end

Чтобы проверить visible?, можно провести достаточно большое количество испытаний и оценить получившийся процент появлений банера:

banner = described_class.new(probability: 0.7)

# Проводим 1000 испытаний, результаты — в массив
trials = Array.new(1000) { banner.visible? }
# Сколько раз visible? вернул true
successful_trials = trials.select { |trial| trial }
# Какой процент
successful_trials_percentage = successful_trials.length.to_f / trials.length.to_f

# Проверяем получившийся процент
expect(successful_trials_percentage).to be_within(0.05).of(0.7)

Этот вариант кажется логичным и математичным. Но с ним есть проблемы: «мигания» и производительность. Тест будет иногда падать в зависимости от количества испытаний: чем меньше испытаний, тем чаще. Чтобы сделать тест стабильным, придётся увеличить количество испытаний и завернуть тест в rspec-retry.

С другой стороны, можно избавиться от рандома. Например, застабив Kernel#rand:

allow(Kernel).to receive(:rand).and_return(0.25)

banner = described_class.new(probability: 0.7)

expect(banner).to be_visible

Или зафиксировав сид:

# Осторожно, магия!
# rand с таким сидом первым числом возвращает 0.37
srand(42)

banner = described_class.new(probability: 0.7)

expect(banner).to be_visible

Ещё по теме:

Лайфхак: процедура завершения работы

Украл лайфхак, чтобы снизить тревогу после работы. Ну вы знаете: рабочий день давно закончился, пора бы уснуть, а ты лежишь в кровати до 2-3 ночи и стрессуешь, бесконечно возвращаясь к работе и задачам. А вот надо было Мише вот так ответить. Блин, на что переходить с Яндекс.Почты? Или лучше заплатить им? Но мы не ведем переговоры с террористами! А когда надо счётчики на воду поверить? Как в билинге учесть, что подарков может быть несколько? А если ещё и одновременно? Зачем я вообще пошёл в программисты?

Чтобы избавиться от этого и нормально спать, я использую процедуру завершения работы:

1. Разбираю инбоксы — почту, телегу и inbox.txt — и распихиваю всё по задачам.

2. Смотрю список дел на сегодня. Всё, что не успел, переношу на завтра или выкидываю.

3. Смотрю список дел на завтра и остаток недели.

4. Разбираю незакрытые вопросы в голове: вытаскиваю в задачи или заметку с идеями «на подумать».

5. Говорю себе: «Теперь питание компьютера можно отключить».

Важный момент: фразу можно использовать любую, главное, доверять ей. Если сказал, значит, всё: рабочий день закончен, всё аккуратно учтено, рассортировано и записано, можно отдыхать.

Мне помогло, попробуйте и вы.

RSpec: to be_within().of()

Как вы прекрасно знаете, в комплуктерах 0,1 + 0,2 ≠ 0,3. Аналогичные проблемы есть и в любых других вычислениях, связанных с дробями (считай, с числами с плавающей запятой). Поэтому в тестах никогда нельзя напрямую сравнивать дробные результаты вычислений.

Используйте be_within(delta).of(expected) — матчер, проверяющий, что полученное число находится в окрестности радиусом delta от нужного:

expect(score).to be_within(0.0001).of(2.19)

Аналогичная история и со временем: в Руби Time имеет точность до наносекунд, а БД — до микросекунд или секунд. В таких случаях приходится сравнивать приведением к юникстайму:

expect(deadline.to_i).to eq Time.new(2022, 7, 7).to_i

Или тем же матчером:

expect(deadline).to be_within(1.second).of(Time.new(2022, 7, 7))

И конечно, если работаете с деньгами, берите BigDecimal.

SPA на Реакте без Вебпака

В конце сентября у меня резко ухудшилось зрение в правом глазу: даже верхнюю строчку таблицы Сивцева видел с трудом. Врачи установили ложную близорукость, назначили лекарства, ограничение зрительных нагрузок (ха!) и гимнастику для глаз «по Аветисову». Конечно, ни одно из приложений с гимнастикой для глаз мне не понравилось, и я написал свое. Чтобы было интереснее, добавил ограничений: Реакт, одна страница и никакого Вебпака.

Оказалось, это ограничение решается довольно легко. Все можно сделать на клиенте:

1. Берем react и react-dom с CDN:

<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>

2. Берем babel с CDN:

<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>

3. Добавляем рутовый элемент, в который отрендерим приложение:

<div id="root"></div>

4. Добавляем <script> в конец body, который babel скомпилирует в рантайме:

<script type="text/babel">

4. Пишем приложение внутри этого скрипта:

const App = (<h1>Ola!</h1>)

ReactDOM.render(<App />, document.getElementById('root'))

5. Вы великолепны!

RSpec: skip и pending

Ситуация: пришли дизайнеры, говорят, СРОЧНО меняем механизм рекомендаций к статье. Вы меняете код, запускаете тесты, получаете кучу ошибок: рекомендации теперь другие, результаты не соответствуют действительности. Дизайнеры и маркетологи стоят у вас над душой и просят выкатить все НЕМЕДЛЕННО. Что делать с «битыми» тестами?

В RSpec есть пара тегов для «пропуска» битых тестов: skip и pending. Добавляем тег к context, describe или it и указываем причину:

it "returns posts with tags commonly used together", skip: "Broken in #45"

it "returns posts with tags commonly used together", pending: "Blocked by #46"

skip и pending отличаются по смыслу: skip отмечает тесты, которые пропускаем по какой-то причине; pending отмечает тесты, которые ждут какого-то события, чтобы заработать. Соответственно, и ведут себя по-разному: skip пропускается, а pending выполняется. Если тесты в pending пройдут, RSpec свалится с ошибкой:

1) returns posts with tags commonly used together FIXED
     Expected pending 'Blocked by #46' to fail. No error was raised.

Короче, если на собеседовании вас спросят, чем отличается skip от pending, смело отвечайте: pending может завалить тесты, а skip — нет.

SSRF

Недавно я узнал об интересном типе атак на веб-приложения: SSRF — server-side request forgery. Идея простая: если ваше приложение принимает ссылки от пользователей, что-то по ним скачивает и отображает, то злоумышленник может скормить ссылку, например, на приватный ресурс в локалке, и получить доступ к данным. Например, в AWS можно использовать http://169.254.169.254/latest/meta-data/, чтобы получить креденшиалы.

Соответственно, уязвимы все места, в которых приложение что-то загружает по ссылке. Если вы пользуетесь готовыми библиотеками, то, скорее всего, в них уже встроена защита от SSRF. Например, Carrierwave использует для этого ssrf_filter.

Чтобы погрузиться в потенциальные варианты атак с помощью SSRF, взгляните на исходники ssrf_filter: https://github.com/arkadiyt/ssrf_filter/blob/main/lib/ssrf_filter/ssrf_filter.rb

Тут всё: локальные айпишники, ipv6, левые URI, бесконечные редиректы, CRLF инъекции, ДНС и публичные адреса.

Range в ActiveRecord

Полюбил открытые и закрытые интервалы в запросах в ActiveRecord. Было:

Charge.where("created_at >= ?", 5.minutes.ago)

Subscription.where("valid_until <= ?", Time.now)

User.where("created_at >= ? and created_at <= ?", 2.weeks.ago, 1.week.ago)

Стало:

Charge.where(created_at: 5.minutes.ago..)

Subscription.where(valid_until: ..Time.now)

User.where(created_at: (2.weeks.ago..1.week.ago))

Не надо везде лепить восклицательные знаки!

Вижу такое:

def finish!
  update(status: :finished)
end

В Руби принято добавлять восклицательный знак к «небезопасным» методам, которые модифицируют оригинальный объект, а не возращают новый:

email = "foo@bar.com"

email.gsub(/foo/, "baz") # вернет новую строку, email останется тем же
email.gsub!(/foo/, "baz") # поменяет email и вернет его

В Рельсах восклицательный знак добавляют к методам ActiveRecord, которые могут выкинуть исключение при ошибках валидации:

user.save # при ошибках вернет false
user.save! # при ошибках выкинет ActiveRecord::RecordInvalid

Оттого, что мы добавили восклицательный знак к finish, лучше АПИ не стало. Наоборот, мы запутали сами себя: безопасного finish не существует, а finish! вряд ли выкинет исключение. Соответственно, метод лучше переименовать тупо в finish.

Планирование преемственности

Читаю сейчас «Элегантный пазл» Уила Ларсона — книгу о системном подходе в управлении и развитии инженерной части организации. Наткнулся на интересное упражнение, succession planning, планирование преемественности.

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

1. Понять, что ты вообще делаешь. Это самый сложный этап: кажется, ты только программируешь, а на деле на тебе еще регулярные встречи, 1:1, спринт планинги, участие в найме и консультации по твоему домену.

2. Разбить получившийся список на два: можно закрыть — передать, делегировать, выкинуть, закрыть документацией — прямо сейчас и самые рискованные штуки, которые кроме тебя никто не сделает.

3. Взять в работу и распланировать на год весь список «прямо сейчас» и одну-две рискованных штуки.

4. Повторить упражнение через год.

yakuake в iTerm

Когда я пользовался Линуксом, обожал yakuake — консоль, которая как в Квейке по хоткею выезжает сверху поверх всего остального. На Маке такого не хватало: иногда хочется быстро что-то ввести в терминал, посмотреть результат и забыть, а приходится запускать или искать терминал, переключаться.

Оказывается, iTerm из коробки умеет выезжать сверху. Ребята спрятали эту фичу здесь: Preferences — Keys — Hotkey — Create a Dedicated Hotkey Window…

Мониторинг ключевых метрик приложения на Рельсах в Графане

Иногда у приложения есть какие-то ключевые метрики, которые могут триггерить алерты, ахтунги и жопы. Например, в условном биллинге это может быть процент неудачных списаний за последний час. Если он высокий, скажем, более 75%, пора заводить инцидент и вызванивать инженеров. Самый толковый способ мониторить такое в приложениях на Рельсах — экспорт ключевой метрики в Графану через Прометеус и Ябеду.

Вот как-то так:

require "yabeda"

Yabeda.configure do
  # Где мы
  group :billing

  # Что мы экспортируем
  gauge :failed_charges_percentage, tags: [], comment: "The percent of failed charges in last hour"

  # Как собираем данные
  collect do
    failed_charges_percentage.set({}, ChargeMetrics.failed_charges_percentage)
  end
end

Лайфхак: буферы для совещаний

Придумал лайфхак, чтобы снизить тревогу и «урон» от совещаний, созвонов и встреч: если созвон с 12 до 13, то в календарь вношу 11:45—13:15.

Буфер в 15 минут до совещания трачу на то, чтобы собраться с мыслями, подготовить повестку и перенастроиться с программирования на разговоры. Без этого буфера первые 5-10 минут совещания провожу в каком-то лимбе: мысли еще в коде, а уже нужно внимательно слушать и как-то реагировать.

Буфер в 15 минут после совещания трачу на то, чтобы разобрать замечания и конспект встречи, раскидать их по задачам и счастливо выдохнуть. Без этого буфера сложно двигаться дальше: мозг возвращается к неразобранным заметкам, злится на неудачные реплики с совещания и подкидывает «надо было сказать вот так».

Мне помогло, попробуйте и вы.

Проблема в занятости, а не в лени

Все мы хотим добиться успеха в карьере, ремесле, бизнесе, спорте или семейной жизни. Кажется, что главные враги на этом пути — лень, низкая мотивация и слабая дисциплина: я ничего не добился из-за него, из-за нее, потому что я ленивый, слабо мотивированный, тряпка и слабак. Кажется, стоит добавить немного насилия над собой, внешней мотивации, и все закрутится-завертится, рост попрет.

Я уверен, что для большинства ребят в айти ключевая проблема — занятость, а не лень или мотивация. И занятость, и Нетфликс расходуют наше время и внимание, ничего не оставляя на целенаправленное развитие. Больше того, занятость отбирает у нас контроль за собственной жизнью и временем: мы уже не хозяева себе, нами рулит календарь с бесконечными встречами, созвонами, стендапами и тревогами.

Отдельно парит, что общество порицает лень, а занятость превозносит. Конечно, в каких-то компаниях важно выглядеть занятым, к таким претензий нет. Но чаще всего занятость — это проблема: посмотрел Gary Vee и 10x programmers, набрал себе работы на выходные, чтобы коллеги видели, как я GET AFTER IT, и сгорел в угли.

Что думаете? Как у вас?

before + after → around

Часто встречаю в тестах такое:

before do
  Timecop.freeze(Time.zone.local(2022, 4, 21))
end

after do
  Timecop.return
end

Или с ActiveSupport::Testing::TimeHelpers:

before do
  travel_to(Time.zone.local(2022, 4, 21))
end

after do
  travel_back
end

Такие конструкции из before с «заморозкой» времени и after с возвратом лучше упрощать с помощью around:

around do |example|
  Timecop.freeze(now) { example.run }
  # или
  travel_to(now) { example.run }
end

Лайфхак: талоны за дипворк

Дипворк (deep work) — это промежуток времени (1,5 часа в моем случае), во время которого я сфокусирован, сосредоточен и не отвлекаюсь на соцсети, месенджеры, телефон и разговоры. Дипворк — это сессии работы над самыми сложными задачами, требующими максимум внимания, ума и сообразительности. Противоположность дипворка — работа, не требующая максимальных усилий: ответы на почту, совещания и созвоны, разговоры по телефону, заполнение форм и документов. Термин я украл у Кэла Ньюпорта из одноименной книги.

На фотке моя обычная неделя: пачка задач на неделю в ЛВУ + списки задач по дням. Возле дат нарисованы «талоны за дипворк», которые я выдаю сам себе, чтобы отслеживать производительность. Отработал 1,5 часа над чем-то сложным — выписал талон, просидел весь день на созвонах и стендапах — ничего не получил.

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

В теории талоны можно собирать и обменивать на что-то прикольное, как в парках аттракционов: собрал шесть талонов за неделю, обменяй на мягкую игрушку. Но мне пока хватает и самого факта собирательства: представляю, что коплю таллоны, как фриманы копили каждую каплю воды ради рая на Арракисе.

inbox.txt

Со мной часто бывало такое: программируешь интересное, а в голову вдруг приходит СРОЧНАЯ и ВАЖНАЯ идея. Если отвлечься на это, голова начнет раскручивать и продумывать идею, что-то планировать, писать письма и гуглить. К коду вернуться сложно: голова занята другим.

Чтобы не отвлекаться в таких случаях, я использую инбокс — текстовый файл inbox.txt, всегда открытый в Саблайме. Появилась идея, опасение, вопрос или что-то, что нельзя забыть, записал в инбокс и продолжил программировать, не отвлекаясь.

Чтобы инбокс работал, мозгу нужны гарантии, что все записанное в инбокс не потеряется. Поэтому у меня есть повторяющаяся задача «Разобрать инбокс», которая стоит сразу после разбора почты в конце рабочего дня. Сделал дело — разбирай инбоксы смело.

Короче, советую: если отвлекаетесь от работы на всякое, закидывайте его в инбокс и забывайте до вечера.

P. S. Использую Саблайм, потому что он не теряет несохраненные данные. Если не сохраню изменения в инбоксе и перезапущу комп, Саблайм их не потеряет.

RSpec & Rails: как проверить deliver_later(wait_until: ...)

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

after_action :send_onboarding_email, only: :create

def send_onboarding_email
  ClientsMailer
    .with(recipient: user)
    .onboarding_email
    .deliver_later(wait_until: 3.days.from_now)
end

Чтобы убедиться, что письмо уйдет точно через три дня, есть два способа:

1. Цепочка из моков-стабов:

mailer = instance_double(ClientsMailer)
email = double(:onboarding_email, deliver_later: true)

allow(ClientsMailer)
  .to receive(:with).with(recipient: user)
  .and_return(email)

# ...

expect(email)
  .to have_received(:deliver_later)
  .with(wait_until: 3.days.from_now)

2. Встроенный матчер и тестовый адаптер для ActiveJob из rspec-rails:

# Это, конечно, лучше спрятать в хелперы
def with_test_active_job_adapter
  last_adapter = ActiveJob::Base.queue_adapter
  ActiveJob::Base.queue_adapter = :test

  yield

  ActiveJob::Base.queue_adapter = last_adapter
end

around do |example|
  with_test_active_job_adapter { example.run }
end

it "schedules onboarding email in 3 days from now" do
  expect { ... }
    .to have_enqueued_job(ActionMailer::MailDeliveryJob)
    .with("ClientsMailer", "onboarding_email", "deliver_now")
    .at(3.days.from_now)
end

P. S. Использовать 3.days.from_now без Таймкопа опасно: могут быть ложноотрицательные тесты.