Canal Ruby Sur En vivo
⏲️ 5:00
Canal Ruby Sur presenta

Cocineros
Rubyistas

Hoy: una suite de tests 5× más rápida,
sin sacar ni un solo test.

Doña Petrona en el set de Canal 9, 1978
Ningún test
fue lastimado

Ficha de la receta

Plato
Suite de tests Rails, al horno
Porciones
5.267 tests · 26.734 assertions
Cocina
App Rails 8.1 multi-tenant · Minitest · Postgres
Cocción original
709 segundos
Cocción nueva
~71 segundos
Dificultad
🥄 Casi todo es configuración
Meme del esqueleto esperando en un banco: 'Yo esperando bin/rails test, 709 segundos'

11 minutos
y 49 segundos

Esta charla dura menos
que mi suite de tests.

Mise en placeLos ingredientes
🔥
1 línea descomentada
parallelize
🧂
1 pizca de sal
bcrypt al costo mínimo
🍽️
1 página de 404 barata
consider_all_requests_local
🗑️
3 cosas que nadie comía
SimpleCov · progress bar · Bullet
🔇
1 relator menos
log level warn
🥡
Postgres sin tupper
unlogged tables
Doña Petrona cocinando con varias ollas en el fuego
Paso 1Prender todas las hornallas
# test/test_helper.rb
# parallelize(workers: :number_of_processors)

Comentada desde el primer commit.
La suite usaba 1 hornalla de 8.

709 → 259 s

2,7× borrando un carácter

Meme de Salt Bae tirando sal: 'BCrypt::Engine.cost = MIN_COST'
Paso 2Una pizca de sal

Devise ya bajaba el costo en test.
Pero solo para Devise.

Nuestro código hasheaba códigos de firma con BCrypt::Password.create a costo 12. Un hash: 1,6 s. El test más lento: 29 s.

# test/test_helper.rb
BCrypt::Engine.cost = BCrypt::Engine::MIN_COST
−44 sBuscá BCrypt::Password.create en tu app
Meme de Scooby-Doo: al desenmascarar 'Tests de autorización lentos' aparece 'La página de 404 que lista todas las rutas'
Paso 3La 404 barata

Pediste un vaso de agua y el mozo te recita la carta entera.

Cada 404 renderizaba la página de desarrollo, con todas las rutas de la app: ~0,7 s por request. En una app multi-tenant, «un extraño recibe 404» es un test que se repite mucho.

# config/environments/test.rb
config.consider_all_requests_local = false
−27 s
Pasos 4, 5 y 6Lo que vuelve intacto a la cocina
📊

SimpleCov

Siempre prendido.
Nadie leía el reporte.

−19 s
📺

minitest-reporters

Redibuja la terminal
después de cada test.

−8 s
🔫

Bullet

Con raise = false:
nunca falla, solo loguea.

−3% CPU
Si no puede romper tu build, solo te cuesta tiempo.
Meme de Gordon Ramsay: '¿Qué sos?' 'Un Bullet con raise = false'
Paso 7Bajarle el volumen al relator
¡SELEEEEEECT!

En test, Rails loguea en debug: cada query se formatea
y se escribe en log/test.log, que llega a gigas.

# config/environments/test.rb
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "warn")
−16 sy −10% de CPU
Paso 8Postgres sin tupper

Los datos de test se tiran al final.
No hace falta que sobrevivan a un apagón.

# config/environments/test.rb
ActiveSupport.on_load(:active_record_postgresqladapter) do
  self.create_unlogged_tables = true
end

Unlogged tables: Postgres no escribe el WAL, el log que usa para recuperarse de un crash.
En CI, además: fsync, synchronous_commit y full_page_writes en off. Tip de @fatkodima ↗

−16 s−10%
Meme de Pikachu sorprendido: 'Postgres: menos trabajo. Benchmark: más lento. Yo:'
BloopersSe me quemó

El primer benchmark dijo que unlogged tables era más lento.

Ese paso corría sobre bases recién creadas: sin estadísticas, con la caché fría. Todos los demás usaban bases calientes.

No estaba midiendo la receta.
Estaba midiendo el horno.

Consejo de la abuelaPrecalentar el horno
  1. Medí con el horno caliente.Bases ya creadas, con ANALYZE.
  2. Alterná las variantes, y medí más de una vez.Misma configuración, dos corridas: 403 s y 202 s.
  3. Chequeá que el cambio se haya aplicado.Revisé relpersistence antes de cada corrida.

Con el horno caliente: logged 168 s · unlogged 152 s

Y acá tengo una que preparé antes…

Suite completa, segundos de reloj, notebook con 4 núcleos. Cada barra suma un cambio a la anterior.

Original, un proceso
709
+ parallelize
259
+ bcrypt mínimo
215
+ 404 barata
188
− SimpleCov
169
− progress bar
161
− Bullet, log warn
158
+ unlogged tables
~142
5×más rápido
Doña Petrona decorando una torta de varios pisos, 1937
la que preparé antes
En la cocina del restauranteCI con 2 CPUs
301 s
antes · mediana de 39 corridas
→
143 s
después · mediana de 3 corridas

2,1× en GitHub Actions. Con 2 procesos, paralelizar da 2× como máximo.
El resto lo ponen los otros ingredientes.

En una notebook con núcleos de sobra, los costos chicos casi no mueven el reloj.
En un runner con 2 CPUs, cada segundo de CPU ahorrado se nota.

Para llevarTips del chef
No estaba en los tests.Lo que más ganó fue configuración: una línea comentada, un costo de bcrypt, una página de error.
Medí cada cambio, más de una vez.Con el horno caliente y alternando. Si no, medís ruido.
Mirá el CPU, no solo el reloj.Lo que en tu notebook no se nota, en CI sí.
Lo que no puede fallar, solo cuesta.Coverage que nadie lee, un Bullet que no rompe, una barrita de progreso.
Sacale una foto 📸La receta entera
# test/test_helper.rb
parallelize(workers: :number_of_processors)
BCrypt::Engine.cost = BCrypt::Engine::MIN_COST

# config/environments/test.rb
config.consider_all_requests_local = false
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "warn")
ActiveSupport.on_load(:active_record_postgresqladapter) do
  self.create_unlogged_tables = true
end
Bullet.enable = true

# Gemfile
gem "simplecov"
gem "minitest-reporters"

¡Buen provecho!

¿Preguntas?

@fesilvajacquier

github.com/fesilvajacquier/test-perf-talk