Rails testlerimi nasıl yazıyorum?

Bu bir rehberdir.

Öncelikle TDD(Test Driven Development - Test Odaklı Geliştirme) nedir onu anlamak lazım. Google a test driven development nedir diye sorduğumda şu kaynaktan

TDD, kod yazılmadan önce test senaryolarının yazılması, bu senaryolara bağlı olarak kodun yazılması ve refactor edilmesi tekniğidir. TDD yaklaşımıyla, yazılması planlanan kodun test senaryoları, sürekli olarak koşturulabilir ve bu sayede programın daha az hata ile geliştirilmesi sağlanabilir.

şeklinde cevap alabiliyoruz. Bence yeterlidir.

Ben de bu TDD denen şeyi benimsemiyordum bir kaç ay öncesine kadar. Peki ne değişti de test yazmaya başladım bu başka bir goygoy konusu. Şimdi test yazmayı nasıl öğrendim ve şuan nasıl yazıyorum onu paylaşacağım.

Bildiğimiz gibi rails ile proje oluşturduğumuzda aksi belirtilmediyse testunit ile geliyor. Test dosyaları da proje/test altında oluyor. Fakat kullanabileceğimiz farklı test frameworkleri de varmış. Github'da rails repolarına bakarken hep spec diye bir dizin görüyordum, tutorial okurken çoğunlukla rspec kullanılıyordu. Biraz araştırarak kaynaklardaki toughtbot dökümanını buldum ve ben de böyle yazayım diye karar verdim. Şimdi konuya gelelim. Bundan sonra rails projelerini rails new proje -T ve ya rails new proje --skip-test ile _-ikisi de aynı- oluşturuyorum. Hatta .railsrc de --skip-test tanımladım.

.railsrc hakkında daha detaylı bilgi için Uğur Özyılmazel(Vigo) abimizin rails.gelistiriciyiz.biz adresindeki şu yazısına bakabilirsiniz.

Proje oluştuktan sonra Gemfile dosyasında aşağıdaki değişiklikleri yapıyorum.

group :development, :test do
  gem 'rspec-rails', '~> 3.5'
end

group :test do
  gem 'database_cleaner'
  gem 'factory_girl_rails', '~> 4.0'
  gem 'faker'
  gem 'shoulda-matchers', '~> 3.1'
end

bundle install diyerek bu yeni gemleri kuruyorum.

rails g rspec:install

Diyerek rspec kuruyorum. factory_girl için mkdir spec/factories diye bir dizin oluşturuyorum.

Rails generator ile bir şey* oluşturduğumda rspec ve factory_girl kullansın diye config/application.rb dosyasına aşağıdakileri ekliyorum.

config.generators do |g|
    g.test_framework :rspec,
        fixtures: true,
        view_specs: false,
        helper_specs: false,
        routing_specs: false,
        controller_specs: false,
        request_specs: true
    g.fixture_replacement :factory_girl, dir: 'spec/factories'
end    

Hoaydaa! Niye view, helper, routing, controller spec leri oluşturulmayacakmış? 🤔

Diye düşünmüştüm sizin gibi. Şurada ve şurada okuduğuma göre request Rails ve Rspec çekirdek ekibi request spec yazmamızı öneriyormuş. Request spec daha kısa yoldan sonuç odaklı testler yazmamı sağlıyor diye düşünüyorum.

Örneğin posts için posts_specs yazıyoruz diyelim, model, crud ve http endpoint için her testi request spec içinde yazabildiğimizi göreceksiniz.

Şimdi başlamadan spec/rails_helper.rb dosyasına aşağıdaki eklemeleri yapıyorum.

# ...

# Add additional requires below this line. Rails is not loaded until this point!
require 'database_cleaner' # test database ini temizlemize yarayacak
require 'devise' # authenticate gerektiren metodlarda helper kullanmamıza yarayacak

# ...

# The following line is provided for convenience purposes. It has the downside
# of increasing the boot-up time by auto-requiring all files in the support
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.

# Özel helper lar tanımlayacağız
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }

# configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

# ...

RSpec.configure do |config|
  # devise helper ların include ediyoruz
  config.include Devise::Test::ControllerHelpers, type: :controller
  # factory_girl metodlarını include ediyoruz
  config.include FactoryGirl::Syntax::Methods
  # tanımlayacağımız spec helper metodlarını include ediyoruz
  config.include RequestSpecHelper, type: :request

  # database_cleaner stratejilerini belirliyoruz
  # start by truncating all the tables but then use the faster transaction strategy the rest of the time.
  config.before(:suite) do
    DatabaseCleaner.clean_with(:truncation)
    DatabaseCleaner.strategy = :transaction
  end

  # start the transaction strategy as examples are run
  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end

  # ...

rails_helper içinde include ettiğim requestspechelper dosyasını spec/support/request_spec_helper.rb adında oluşturuyorum. Ve içine oturum gerektiren testlerde kullanacağım metodları yazıyorum.

module RequestSpecHelper
  include Warden::Test::Helpers

  def self.included(base)
    base.before(:each) { Warden.test_mode! }
    base.after(:each) { Warden.test_reset! }
  end

  def sign_in(resource)
    login_as(resource, scope: warden_scope(resource))
  end

  def sign_out(resource)
    logout(warden_scope(resource))
  end

  private

  def warden_scope(resource)
    resource.class.name.underscore.to_sym
  end
end

Şimdi anlatırken user / post / comment örneği kullanacağım. İlk iş test için model factory ve ya başka deyişle test datalarını hazırlıyorum.

# spec/factories/users.rb

FactoryGirl.define do
  factory :user do
    name { Faker::Name.name }
    email { Faker::Internet.email }
    password 'password'
    password_confirmation 'password'
  end
end

#########################
# spec/factories/posts.rb

FactoryGirl.define do
  factory :post do
    user_id 1
    title { Faker::Lorem.sentence }
    body { Faker::Lorem.sentences(20).join(' ') }
  end
end

############################
# spec/factories/comments.rb

FactoryGirl.define do
  factory :comment do
    post_id 1
    user_id 1
    comment { Faker::Lorem.sentences(3).join(' ') }
  end
end

Factory yazarken Faker ne işe yaradı görüyoruz.

Sonra model testlerini yazarak devam ediyorum.

# spec/models/user_spec.rb

require 'rails_helper'

RSpec.describe User, type: :model do
  it { should have_many(:posts).dependent(:destroy) }
  it { should have_many(:comments).dependent(:destroy) }

  # Validation testleri
  it { should validate_presence_of(:email).on(:create) }
  it { should validate_uniqueness_of(:email).ignoring_case_sensitivity }
  it { should validate_presence_of(:password).on(:create) }
  it { should validate_confirmation_of(:password) }
end

##########################
# spec/models/post_spec.rb

require 'rails_helper'

RSpec.describe Post, type: :model do
  it { should belong_to(:user) }
  it { should have_many(:comments).dependent(:destroy) }

  # Validation testleri
  it { should validate_presence_of(:title) }
  it { should validate_presence_of(:body) }
end

#############################
# spec/models/comment_spec.rb

require 'rails_helper'

RSpec.describe Comment, type: :model do
  it { should belong_to(:post) }
  it { should belong_to(:user) }

  # Validation testleri
  it { should validate_presence_of(:comment) }
end

Eğer modellerimi gerçekten bu şekilde oluşturduysam bundle exec rspec komutunu çalıştırdığımda hiç bir hata almayacağım. Eğer modellerimde testlerde belirtildiği gibi ilişkiler ve ya validation tanımlanmamışsa bunu güzel güzel belirten hatalar alacağım.

Şimdi request testlerinden bahsedeyim.

User için test yazmaya gerek duymuyorum çünkü zaten override yapmadıysam kullandığım gem de testleri doğru çalışmış durumda. O yüzden sadece posts testini nasıl yazdığımı göstereceğim.

# spec/requests/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  # bir kullanıcı oluşturuyorum
  let(:user) { create(:user) }
   # o kullanıcı ile bir post oluşturuyorum
  let(:post) { create(:post, user: user) }

  describe 'GET /posts' do
    before { get '/posts' }

    it 'returns 200 and render posts/index template!' do
      expect(response).to have_http_status(200)
      expect(response).to render_template(:index)
    end
  end

  describe 'GET /posts/:id' do
    before { get "/posts/1" }

    context 'when the record exists' do
      it 'returns 200 and render posts/show template!' do
        expect(response).to have_http_status(200)
        expect(response).to render_template(:show)
      end
    end

    context 'when the record does not exists' do
      it 'returns 404 and render posts/show template!' do
        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'POST /posts' do
    context 'when authorized' do
      # bu action oturum gerektirdiği için oluşturduğum
      # user ile aşağıdaki devise helper methodunu kullanarak oturum açtırıyorum
      before { sign_in user }

      it 'create new post with valid attributes' do
        post "/posts", :post => {:title => "My post", :body => "Lorem ipsum dolor sit amet"}
        expect(response).to redirect_to(assigns(:post))
        follow_redirect!

        expect(response).to render_template(:show)
        expect(response.body).to include("Post was successfully created.")  
      end

      it 'create new post with invalid attributes' do
        post "/posts", :post => {:title => "My post"} # body yok
        expect(response).to have_http_status(422)
        expect(response.body).to match(/param is missing or the value is empty/)
      end
    end

    context 'when not authorized' do
      # bu action oturum olmayan durumu test etmek için olduğundan sign_in user yapmıyorum

      it 'redirects sign_in page' do
        post "/posts", :post => {:title => "My post"}
        expect(response).to redirect_to(new_user_session_url)
      end
    end
  end
end

Bu örnekte temel testleri nasıl yazdığımı gösterdim fakat durum bazen daha karışık olabilir tabii ki. Bunların dışında response ile ilgili farklı farklı testler yazmamız gerekebilir. Çok çok daha fazlası için rspec dökümantasyonuna bakmanızı öneririm. Ben kısaca test yazmak için nasıl bir yol izlediğimi göstermek istedim. Daha doğru yolunu bilen ve aydınlatmak isteyenler lütfen yorumlarda aydınlatsın. Umarım faydalı olmuştur. Esen kalın 🙏🏻

Kaynaklar :