🍪🧪 Écrire des tests qui évitent les régressions
Temps de lecture : 8 minutes
Hello les petits Biscuits !
Bienvenue sur la 42ème édition de Ruby Biscuit.
Vous êtes maintenant 610 abonnés 🥳
Annonce : On recrute ! Capsens cherche un·e dev backend Ruby on Rails confirmé·e à Lyon. Toutes les infos ici
Bonne lecture.
Je me souviens d’une époque pas si lointaine, quand j’ai commencé ce métier de développeur web, où écrire des tests automatiques, eh bien ça n’existait tout simplement pas ! À contrario, aujourd’hui, je ne connais plus personne qui n’en écrit pas. Alors pourquoi ? À quoi ça sert les tests ? Et comment maximiser leur efficacité en minimisant l’effort nécessaire pour les écrire ?
À quoi ça sert ?
Entre le monde d’avant les tests, et le monde d’après, une chose principalement est devenue beaucoup, beaucoup, plus facile : modifier son programme sans peur. Sans peur des effets de bord, sans avoir besoin de re-tester à la main toutes les fonctionnalités pour voir si on n’a pas introduit quelque part un bug inattendu. Sauf si vous pratiquez le TDD, les tests ne vous aident pas à développer. Mieux vaut jouer des scénarios à la main en local pour voir comment fonctionne ce que vous êtes en train de coder plutôt que d’écrire des tests. Les tests ne font pas non plus une bonne documentation. Mieux vaut écrire du code lisible et bien découpé, et vous n’aurez pas besoin de tests pour comprendre votre code. Les tests sont INDISPENSABLES pour une chose : vérifier que toutes les autres fonctionnalités de l’app fonctionnent toujours après votre passage. Et c’est donc pour cette raison qu’il faut écrire des tests; des tests qui répondent à cette problématique !
Écrire des tests qui évitent les régressions !
Dans la grande famille des tests, on peut différencier trois grands groupes : les tests dits “unitaires”, qui testent de très petits bouts de code (une seule méthode par exemple). Les tests dits “d’intégration”, qui testent plusieurs petits bouts de code en même temps (une méthode qui appelle une méthode qui appelle une méthode). Et finalement les tests dits “end to end”, qui simulent l’ensemble de l’application, en simulant directement les interactions de l’utilisateur.
Afin de prévenir les bugs en production, le mieux serait d’écrire des tests end to end. Ils testent toute la stack, tous les appels, tout ! Si un utilisateur en prod a un bug, un test end to end qui simule le même parcours l’aurait forcément vu (et donc évité). Sauf que les tests end to end c’est loooong et compliquéééé à écrire. Et c’est looooooong à exécuter aussi ! Franchement, quel développeur peut se permettre d’attendre une heure (ou plus) à chaque fois qu’il/elle lance un rspec ?
Les tests end-to-end ont clairement leur places dans votre suite de tests. En particulier pour les parties les plus sensibles ou les scénarios les plus communs de votre application. Mais ils ne constitueront pas la majorité de vos tests.
Pour cela, il nous reste donc deux prétendants à analyser, les tests unitaires et les tests d’intégrations. Lesquels sont les meilleurs pour repérer les bugs d’effets de bord ?
Lesquels sont les meilleurs ?
Imaginons que j’ai ces bouts de code dans mon appli Rails :
# app/models/user.rb
class User < ApplicationRecord
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
end
#app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.create(user_params)
@user.send_welcome_email
end
def user_params
params.require(:user).permit(:email)
end
endEt les tests unitaires qui vont avec :
# spec/models/user_spec.rb
RSpec.describe User do
describe "after_create callback" do
it "#send_welcome_email" do
user = build(:user)
expect(UserMailer).to receive(:welcome)
user.send_welcome_email
end
end
end
# spec/controllers/users_controller_spec.rb
RSpec.describe UsersController do
describe "POST #create" do
it "sends an email" do
user = instance_double(User)
allow(User).to receive(:create).and_return(user)
allow(user).to receive(:send_welcome_email)
post :register, params: { email: "toto@email.fr" }
expect(user).to have_received(:send_welcome_email)
end
end
endJusqu’ici tout va bien. Les tests sont verts, tout fonctionne normalement, je peux envoyer mon code en production sans inquiétude.
Et puis, un beau jour, quelques mois plus tard, je crée une version “API” de la création d’utilisateurs, avec un twist :
# app/controllers/api/users_controller.rb
module API
class UsersController < APIController
def create
@user = User.create(user_params)
@user.send_welcome_email
end
def user_params
params.require(:user).permit(:email, :role)
end
end
end
# app/models/user.rb
class User < ApplicationRecord
validates :role, inclusion: {in: %w[admin customer], allow_blank: true}
def send_welcome_email
UserMailer.welcome(self).deliver_later if role == "customer"
end
endMon API permet de gérer plusieurs “rôles”, les clients et les admins. Et forcément, je n’envoie pas d’emails aux admins, ils n’en ont pas besoin. Bon, maintenant il faut que je fasse mes tests unitaires. J’en rajoute un pour mon nouveau controller, et comme j’ai modifié le modèle bah je vais devoir modifier le test pour cette méthode.
# spec/controllers/users_controller_spec.rb
RSpec.describe API::UsersController do
describe "POST #create" do
it "sends an email" do
user = instance_double(User)
allow(User).to receive(:create).and_return(user)
allow(user).to receive(:send_welcome_email)
post :register, params: { email: "toto@email.fr", role: "customer" }
expect(user).to have_received(:send_welcome_email)
end
end
end
# spec/models/user_spec.rb
RSpec.describe User do
describe "after_create callback" do
it "#send_welcome_email" do
user = build(:user, role: "customer")
expect(UserMailer).to receive(:welcome)
user.send_welcome_email
end
end
endSuper chouette. J’ai fini mon code, tous les tests sont verts, je peux envoyer sereinement mon code en production…
Et là …
En fait, personne ne le remarque vraiment, et c’est grâce aux clients qui commencent à se plaindre au service client qu’on s’en rend compte : les utilisateurs qui s’inscrivent par la voie “classique” ne reçoivent plus l’email de bienvenue (qui contient plein d’infos importantes).
Et mon chef me demande pourquoi je ne m’en suis pas rendu compte avant alors même qu’on alloue 40% du temps à écrire des tests !
Si seulement j’avais écrit un test d’intégration plutôt qu’un test unitaire, par exemple :
# spec/controllers/users_controller_spec.rb
RSpec.describe UsersController do
describe "POST #create" do
it "sends an email" do
expect {
post "/users", params: { user: {email: "toto@email.fr"} }
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
end
endCelui-ci aurait raté quand j’ai introduit la notion de rôles. Parce qu’il n’a pas de mock. Parce les tests d’intégration évitent une grande partie des mocks, alors que les tests unitaires en utilisent beaucoup pour limiter le scope de ce qu’ils testent. Et que les mocks il faut les maintenir. Et que cette fois-ci j’ai oublié.
Premier point marqué en faveur des tests d’intégration. Continuons avec un deuxième exemple.
Le deuxième exemple ?
Mettons que j’ai un service :
# app/services/order_total.rb
class OrderTotal
def initialize(order)
@order = order
end
def call
@order.items.sum(&:price)
end
end
# spec/services/order_total_spec.rb
describe OrderTotal do
it "returns the sum of item prices" do
order = double(items: [
double(price: 10),
double(price: 20)
])
expect(OrderTotal.new(order).call).to eq(30)
end
endEt un controller :
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def show
order = Order.find(params[:id])
total = OrderTotal.new(order).call
render json: { total: total.to_i }
end
end
# spec/controllers/orders_controller_spec.rb
describe OrdersController do
it "returns the order total" do
order = double(id: 1)
allow(Order).to receive(:find).and_return(order)
allow(OrderTotal).to receive_message_chain(:new, :call)
.and_return(30)
get :show, params: { id: 1 }
expect(response.body).to include("30")
end
endMaintenant, notre app évolue et on doit rajouter le calcul de la TVA. Comme notre code est bien découpé, c’est facile, il suffit de modifier le service :
# app/services/order_total.rb
class OrderTotal
def initialize(order)
@order = order
end
def call
{
subtotal: subtotal,
tax: tax,
total: subtotal + tax
}
end
private
def subtotal
@order.items.sum(&:price)
end
def tax
subtotal * 0.2
end
end
# spec/services/order_total_spec.rb
describe OrderTotal do
it "returns a detailed total hash" do
order = double(items: [
double(price: 10),
double(price: 20)
])
result = OrderTotal.new(order).call
expect(result[:total]).to eq(36)
end
endEt maintenant, deux options : soit on a écrit des tests unitaires, comme je l’ai fait dans cet exemple. Dans ce cas les tests sont tous verts, et je vais soit leur faire confiance et envoyer un bug en production, soit ne pas leur faire confiance et rechercher manuellement tous les endroits qui auraient pu être impactés par ma modif (et dans ce cas, j’ai le risque d’en oublier, et aussi, pourquoi je m’embête à écrire des tests ?).
Ou bien, j’ai écrit des tests d’intégration, comme celui-ci :
# spec/controllers/orders_controller_spec.rb
describe "GET /orders/:id" do
it "returns the correct total" do
order = create(:order)
create(:item, order: order, price: 10)
create(:item, order: order, price: 20)
get "/orders/#{order.id}"
json = JSON.parse(response.body)
expect(json["total"]).to eq(36)
end
endEt le test serait devenu rouge. Et je me serai rendu compte que maintenant le service OrderTotal me renvoie un Hash, et que {}.to_i ça renvoie 0 !
Un second point pour les tests d’intégration, qui nous ont évité de grosses gouttes de sueur.
Comment écrire de bons tests d’intégration
Avant de conclure, j’aimerais attirer votre attention sur un point important : les tests d’intégration ne vous sauveront la mise que s’ils scénarisent toutes les variantes possibles du code.
Prenez cet exemple, j’ai des documents associés à mes utilisateurs, et un mécanisme d’autorisations qui limite l’accès d’un utilisateur à ses propres documents uniquement.
# app/models/document.rb
class Document < ApplicationRecord
belongs_to :user
end
# app/policies/document_policy.rb
class DocumentPolicy
def initialize(user, document)
@user = user
@document = document
end
def show?
@document.public? || owner?
end
private
def owner?
@document.user_id = @user.id
end
end
# app/controllers/documents_controller.rb
def show
document = Document.find(params[:id])
authorize document
render json: document
endEt le test d’intégration qui va avec :
# spec/controllers/documents_controller_spec.rb
describe "GET /documents/:id" do
it "allows the owner to see the document" do
user = create(:user)
document = create(:document, user: user, public: false)
sign_in user
get "/documents/#{document.id}"
expect(response).to have_http_status(:ok)
end
endVous, cher lecteur, aurez peut-être vu le bug (dans la méthode DocumentPolicy#owner?, le développeur a confondu le symbole d’une assignation = avec le symbole d’une égalité ==), mais mon test d’intégration lui ne verra rien du tout. Pourquoi ? Parce que je ne teste que le “chemin heureux”, le cas où tout va bien. Pour bien faire, il FAUT que j’ajoute un test qui vérifie quand ça ne fonctionne pas :
# spec/controllers/documents_controller_spec.rb
describe "GET /documents/:id" do
it "allows the owner to see the document" do
user = create(:user)
other_user = create(:user)
document = create(:document, user: other_user, public: false)
sign_in user
get "/documents/#{document.id}"
expect(response).to have_http_status(:forbidden)
end
endÀ cette condition seulement je peux me fier à mes tests d’intégration. C’est donc une nécessité absolue de vérifier et revérifier que vos tests d’intégration couvrent correctement tous les différents cas qui pourraient se produire. Faites relire vos tests, demandez au relecteur de se concentre particulièrement fort sur les tests, pour vérifier que vous n’avez rien laissé passer.
Vos tests ne sont utiles QUE si vous leur faites confiance. Et pour leur faire confiance, il faut qu’ils détectent les bugs, et qu’ils testent exhaustivement ce qui se passe dans votre code.
Les tests unitaires sont-ils utiles ?
À me lire, vous pourriez commencer par croire que les tests unitaires ne servent à rien. C’est bien normal car j’ai écrit cet article pour argumenter que les tests d’intégration sont plus intéressants que les tests unitaires.
Afin d’apporter un peu de nuance dans mon propos, discutons autour d’un exemple. Prenons le code suivant :
# app/controllers/subscriptions_controller.rb
def create
@subscription = Subscription.new(subscription_params)
CheckSubscriptionValidity.new(@subscription)
SubmitSubscriptionToGoverment.new(@subscription)
NotifySubscriptionHolder.new(@subscription)
@subscription.save
render @subscription
endÀ votre avis, quelle suite de test sera la plus simple et rapide à écrire, relire et maintenir :
Option A : Des tests unitaires pour chacun des trois services qui sont utilisés dans cette action, plus un ou deux tests d’intégration qui vérifient que l’ensemble fonctionne correctement.
Option B : Des tests d’intégration uniquement, autant qu’il faut pour couvrir toutes les combinaisons possibles et imaginables.
Dans cette situation aucune des deux options ne se démarque vraiment. Vous pourriez choisir l’option A, je pourrais choisir l’option B, et nous aurions tous les deux raison.
Conclusion
Peu importe que vous écriviez des tests unitaires, d’intégration ou end-to-end, mais pour écrire de bons tests, ce que je veux vous dire c’est :
Couvrez TOUS les scénarios
Utilisez les tests qui simulent au plus près le comportement d’un utilisateur réel
Autant que possible en prenant en compte les limitation de temps et de ressources dont vous disposez
Si vous faites cela, vous pourrez enfin modifier votre application sans peur, et arrêter d’’envoyer des bugs en production.
Outro
Et voilà ! Merci à tous d’avoir lu mon avis autour de la politique de tests dans une équipe technique. J’espère que j’ai su apporter des arguments convaincants et que cet article sera la source de nombreux débats à travers toutes les boîtes tech de France. Prenez soin de vos collègues et à bientôt !
— Thomas
