Sign Up
Hero

How to Add Authorization to a Rails API Following TDD

Learn how to secure a Rails API with Auth0 by letting you drive by tests.

When you're adding authorization to an application, there are two crucial questions:

  • Are users that shouldn't have access actually out?
  • Are users that should have access actually in?

If you can't answer both questions with certainty, how can you claim to have a secure application? This is something that you can test manually, but a better alternative is test automation. Concretely, I think the best methodology to get there is using Test-Driven Development (TDD).

This piece is about adding authorization to a Ruby on Rails API by following TDD. You can follow this article along with this repository.

You can see these security concepts in practice using the Rails Authentication By Example guide and the Rails Authorization By Example guide, which also covers the concept of Role-Based Access Control (RBAC).

The TDD Cycle

In its essence, TDD is about a loop with the three steps shown in the following picture:

[Source: Thoughtworks]

  • First, you make a test for a new feature. Initially, the test will fail.
  • Then, you write the minimum amount of code that makes the test pass.
  • Lastly, you refactor the code to make the implementation more solid.

Simple, isn't it? It creates a feedback loop where you write code incrementally to fulfill the task. Moreover, it ensures that you build testability, meaning you write your code so that it can be tested.

To show how to use TDD, let's add authorization to your application step by step, starting with tests. I will use OAuth, a battle-tested and widely used authorization framework for web applications, to authorize requests to the API via Auth0.

In this context, Auth0 fulfills the role of the authorization server and abstracts a significant part of the work away from you. That way, you can focus on delivering value to your users.

Getting Started

We're getting started with our base application. This branch is a good starting point. You can download it by running the following command in a terminal window:

git clone -b starter --single-branch https://github.com/auth0-blog/securing-api-rails.git

The API has three endpoints with different levels of protection:

  • /api/messages/public: Public route.
  • /api/messages/protected: Requires a valid access token.
  • /api/messages/admin: Requires a valid access token. Since Auth0 uses JWT as its access token format, we can inspect it and make sure it has a permissions claim that contains the scope read:admin-messages.

Running the Application

To run the application, we first need the correct ruby version. The easiest way to do so is to use a version manager like rbenv. Once you install it, run this command inside the repository to install the right version of ruby:

rbenv install

Install the dependencies for the application:

bundle install

And finally, run the application:

bin/rails s

You can verify that the application is working correctly with curl:

curl localhost:6060/api/messages/public

The command will return a 200 code plus the message:

{"message": "The API doesn't require an access token to share this message."}

Creating an API on Auth0

To secure the API with Auth0, you need an Auth0 account. If you haven't one, you can sign up for free right now. In the APIs section of the Auth0 dashboard, click Create API. Provide a name and an identifier for your API. You will use the identifier as an audience later when configuring the access token verification. Leave the Signing Algorithm as RS256.

Once you create the API, go to the Permissions tab in the API details and add permission called read:admin-messages.

Note: While in the Auth0 Dashboard, take note of your Auth0 domain. You will need it soon. The domain is a string in the form YOUR-TENANT-NAME.auth0.com where YOUR-TENANT-NAME is the name you provided when you created your account with Auth0. For more information, check the documentation.

Connecting the application to Auth0

All right, your application is ready to go and in dire need of some security. Before that, you need to add some configurations.

After creating the API, you dutifully stored the domain and the audience, right? Let's use them. The convention in the Rails world is to add this in the config folder using YAML. The file is called config/auth0.yml.

You don't want to store credentials in our code, so you'll export the values as environment variables, named AUTH0_DOMAIN and AUTH0_AUDIENCE. The configuration uses those values and keeps them safely away from source control!

development:
  issuerUri: <%= ENV["AUTH0_DOMAIN"] %>
  audience: <%= ENV["AUTH0_AUDIENCE"] %>

Now, set AUTH0_DOMAIN and AUTH0_AUDIENCE environment variables to your API's domain and audience values. I used the dotenv-rails gem, but you can use what works best for you.

The Initial Tests

Let's start by decoding the access token in the JWT format you get from Auth0. You're leveraging the excellent jwt gem. You don't want to test that the library works, so you will be relatively sparse with the testing. You want to make sure that incorrect requests fail.

First, start by adding the jwt gem to your Gemfile:

gem 'jwt'

Then make sure that it installs successfully by running in your terminal:

bundle install

If you go to spec/api/messages_controller_spec.rb, you'll find the baseline tests for the MessagesController routes that were described above:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  describe '#public' do
    subject { get :public, params: { format: :json } }

    it 'returns an accepted answer for the public endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API doesn\'t require an access token to share this message.'
      expect(json_response!).to include('message' => message)
    end
  end

  describe '#protected' do
    subject { get :protected, params: { format: :json } }

    it 'returns an accepted answer for the protected endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API successfully validated your access token.'
      expect(json_response!).to include('message' => message)
    end
  end

  describe '#admin' do
    subject { get :admin, params: { format: :json } }

    it 'returns an accepted answer for the admin endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API successfully recognized you as an admin.'
      expect(json_response!).to include('message' => message)
    end
  end
end

You're not enforcing authorization yet. The requests work, but that'll change soon enough.

At this point, you can launch the tests. Move to the project's root folder and run the following command:

./go test

The go script allows you to execute different tasks, but you will use it here to run our tests.

For now, you have a pleasant list of green tests, as you can see in the following screenshot:

Once the gem has been installed and you have seen the tests run, create a lib folder under the spec folder and add a file named json_web_token_spec.rb with the following content:

# spec/lib/json_web_token_spec.rb

require 'rails_helper'

require 'json_web_token'

describe JsonWebToken do
  subject { described_class }

  # rubocop:disable Layout/LineLength
  let(:token) do
    'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FTXpRakpEUTBSRk4wUXlNemxETmpVME1VRTFNak00TWpsQ09UWXdNamMzTlVWQk9UUkVSZyJ9.eyJpc3MiOiJodHRwczovL2hjZXJpcy5ldS5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTE0NjA0MTk0NTcyODg5MzU3ODciLCJhdWQiOiJzaGVsZjIuaGNlcmlzLmNvbSIsImlhdCI6MTU1NTcxNzM1MywiZXhwIjoxNTU1NzI0NTUzLCJhenAiOiJxMU1Ebmhwa0VDRGJqU2RBOU1Tc2ROUmJYRUtoV0lZaiIsInNjb3BlIjoicHJvZmlsZSBjcmVhdGU6Ym9va3MifQ.HTPZ3ISGdzUYc190vq8rN8lfQKvgg47uIbxGfBmrbJfsQOEg2TQ-oMlTV3j8e486zhlu1NAHh2neIhMmgfJpxuXkMQrnxCwSb_sSHpNU7TNwNY9hnATvU3nslqz-4VW1FwOxtjF38k7uVqZ9Xusm2skH5DR6BPh3lU2T-I79OMVHfQb47vzNBfbCu6xx9cGBzdeJdu9ADHJOnhE8PRp4fpdQ8lDm3hNAMDaKrKXBS49HfxSsEswC5u6WR5FnWm7hCe4CFNBuosMohRkDSGRWGwQcVIAzaQASXMx1NsWpkBSBytlCsQkxYaVK7dV1syXeXqJSCoZKcRHpF-hL50xrOw'
  end

  let(:jwks_raw) do
    # rubocop:disable Style/StringLiterals
    "{\"keys\":[{\"alg\":\"RS256\",\"kty\":\"RSA\",\"use\":\"sig\",\"x5c\":[\"MIIDATCCAemgAwIBAgIJUehs79ahslK3MA0GCSqGSIb3DQEBCwUAMB4xHDAaBgNVBAMTE2hjZXJpcy5ldS5hdXRoMC5jb20wHhcNMTkwNDE2MTkwNzQ3WhcNMzIxMjIzMTkwNzQ3WjAeMRwwGgYDVQQDExNoY2VyaXMuZXUuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAso5viNLtITh86OESO6njyqbtf+iPBEcQNmWohKEKMSDTeeWxJP15mWDUPB+EAKTakudsJ/Rs/MiTiEHOJubJ6BVMYyPd/3E9G2fj5KCbHF9140H4UyJfGk9jlYtKZGPJ1QlzxEZ1Krr4LSMO+P/PjD606wPSW6bd9dAUufmYTTJOpNQW/dw0V6meAr1fm1267f5XCJfjMkzQQmFtSpxDN/IpzJgWcjEsQU/0r+KSdzKf7viqotfK9soDuvni292dNzrLDiwMLWth9+6JVi6TMV5uJPfbJInQgOoaRowPWVquavNxXk/hrur4aBdP229jUe9wX+wk5MGV/uzGbEj59QIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS9AsVL15G7Z9uI6p/7I7O7aHaCPDAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBALIBpf5Aizfgw2Dge8xJyKELO6kRO0nrBFNyP0viajcRA3jwl9LuV316TjE8eIitmEM0nP4U9AeSkeEPksJBHMak4w+GuE7SkeZ5z6fjpNcZ/1nzJVZMDftjJDNbLeCXO/5bq6ySzYVl53pg5I3auLwEEDcrZKHhRjW0IHxBSqmhYZGajymAaBltHsYS8NP6TfDaT1dXw2EQwgIjxXeoGaQTieX0blGjrJ2y8IRBp1EZ9w2OdHaLEbkD08ndn1m5mQrkX/+F2cSiDZTtrm5Isw1TEJusBbM0j+kEsdwz2VijWIL5K2wjgLMm+tBd5OtibDSoeCNqBW+F/sjtBlMcTq4=\"],\"n\":\"so5viNLtITh86OESO6njyqbtf-iPBEcQNmWohKEKMSDTeeWxJP15mWDUPB-EAKTakudsJ_Rs_MiTiEHOJubJ6BVMYyPd_3E9G2fj5KCbHF9140H4UyJfGk9jlYtKZGPJ1QlzxEZ1Krr4LSMO-P_PjD606wPSW6bd9dAUufmYTTJOpNQW_dw0V6meAr1fm1267f5XCJfjMkzQQmFtSpxDN_IpzJgWcjEsQU_0r-KSdzKf7viqotfK9soDuvni292dNzrLDiwMLWth9-6JVi6TMV5uJPfbJInQgOoaRowPWVquavNxXk_hrur4aBdP229jUe9wX-wk5MGV_uzGbEj59Q\",\"e\":\"AQAB\",\"kid\":\"NEMzQjJDQ0RFN0QyMzlDNjU0MUE1MjM4MjlCOTYwMjc3NUVBOTRERg\",\"x5t\":\"NEMzQjJDQ0RFN0QyMzlDNjU0MUE1MjM4MjlCOTYwMjc3NUVBOTRERg\"}]}"
    # rubocop:enable Style/StringLiterals
  end
  # rubocop:enable Layout/LineLength

  let(:jwks_response) { Net::HTTPSuccess.new(1.0, '200', body: jwks_raw) }
  describe '.verify' do
    before do
      allow(Net::HTTP).to receive(:get_response).and_return(jwks_response)
      allow(jwks_response).to receive(:body).and_return(jwks_raw)

      allow(Rails.configuration).to receive_message_chain('auth0.domain').and_return('AUTH0_DOMAIN_STUB')
      allow(Rails.configuration).to receive_message_chain('auth0.audience').and_return('AUTH0_AUDIENCE_STUB')
    end

    it 'shows an error if the token is incorrect' do
      expect(subject.verify('').error.message).to eq("Not enough or too many segments")
    end

    it 'shows an error if the token is expired' do
      expect(subject.verify(token).error.message).to eq("Signature has expired")
    end
  end
end

In this test, you are using RSpec's allow method, what allow does is allow us to test the class Net::HTTP for example, to receive a method called get_response and return a Net::HTTPSuccess.

You are also mocking the response of the .well-known endpoint and its body and also the Rails configuration. You could load your env variables to be accessed from the test, but for the sake of this blog post, we are going to mock them.

Next, you need to wrap the jwt library with a little bit of code. For this purpose, add a file named json_web_token.rb to the lib folder in the project's root. Put the following code in that file:

# lib/json_web_token.rb

require 'jwt'
require 'net/http'

class JsonWebToken
  class << self
    Error = Struct.new(:message, :status)
    Response = Struct.new(:decoded_token, :error)

    def domain_url
      "https://#{Rails.configuration.auth0.domain}/"
    end

    def verify(token)
      jwks_uri = URI("#{domain_url}.well-known/jwks.json")
      jwks_response = Net::HTTP.get_response jwks_uri

      unless jwks_response.is_a? Net::HTTPSuccess
        error = Error.new('Unable to verify credentials', :internal_server_error)
        return Response.new(nil, error)
      end

      jwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keys

      decoded_token = JWT.decode(token, nil, true, {
                                   algorithm: 'RS256',
                                   iss: domain_url,
                                   verify_iss: true,
                                   aud: Rails.configuration.auth0.audience.to_s,
                                   verify_aud: true,
                                   jwks: { keys: jwks_hash[:keys] }
                                 })
      Response.new(decoded_token, nil)
    rescue JWT::VerificationError, JWT::DecodeError => e
      error = Error.new(e.message, :unauthorized)
      Response.new(nil, error)
    end
  end
end

Here you are using a couple of Ruby Struct to represent a response and error. In a nutshell, the verify method calls the .well-known endpoint in order to retrieve the JWKS. Then, the JWT.decode method attempts to decode the token passed as a parameter. If everything goes well, you return the decoded token. Otherwise, you return an error.

If you want to learn more about the arguments of the JWT.decode function and the JSON Web Key Set (JWKS) you can read more about it in the Rails Authorization Guide by Example, specifically the section "What is the Auth0Client Class doing under the hood".

If you want to learn more about the arguments of the JWT.decode function and the JSON Web Key Set (JWKS), you can read more about it in the Rails Authorization By Example specifically the section "What is the Auth0Client Class doing under the hood?".

Testing Authorization

Let's build our tests to verify that the protected endpoint works as expected.

Note you don't want to test actual tokens. What you want to test is that your code works. In this case, the verify method works. It could be a bit tricky in this scenario because the verify method is only used as a wrapper for the JWT.decode function, so it might give you the impression you need to test the JWT.decode method, but this is not the idea of unit testing.

Let's go ahead and add the stub in messages_controller_spec.rb:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
     # ...existing code...
  
     subject { get :protected, params: { format: :json } }

     it 'returns an accepted answer for the protected endpoint' do
      # 👇 new code
      allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))
      # 👆 new code

      subject
      expect(response).to be_ok

      # 👇 new code
      message = 'The API successfully validated your access token.'
      expect(json_response!).to include('message' => message)
      # 👆 new code
     end

     # ...existing code...

     subject { get :admin, params: { format: :json } }

     it 'returns an accepted answer for the admin endpoint' do
      # 👇 new code
      allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))
      # 👆 new code

      subject
      expect(response).to be_ok

      # 👇 new code
      message = 'The API successfully recognized you as an admin.'
      expect(json_response!).to include('message' => message)
      # 👆 new code
     end
  
     # ...existing code...
end

These tests will prevent regressions when we enforce authorization.

Let's add some tests to define the expectations you want to enforce in the protected route:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  describe '#protected' do
    context 'with error' do 
      it 'returns an error for the protected endpoint if the token has the wrong audience' do
        message = 'Invalid audience'
        error_struct = double(message: message, status: :unauthorized)
        response_struct = double(decoded_token: nil, error: error_struct)
        allow(JsonWebToken).to receive(:verify).and_return(response_struct)

        subject

        expect(response).to be_unauthorized
        expect(json_response!).to include('message' => message)
      end

      it 'returns an error for the protected endpoint if there is no token' do
        message = 'Nil JSON web token'
        error_struct = double(message: message, status: :unauthorized)
        response_struct = double(decoded_token: nil, error: error_struct)
        allow(JsonWebToken).to receive(:verify).and_return(response_struct)

        subject

        expect(response).to be_unauthorized
        expect(json_response!).to include('message' => message)
      end

      it 'returns an error for the protected endpoint if the token is expired' do
        error_struct = double(message: message, status: :unauthorized)
        response_struct = double(decoded_token: nil, error: error_struct)
        allow(JsonWebToken).to receive(:verify).and_return(response_struct)

        subject

        expect(response).to be_unauthorized
        expect(json_response!).to include('message' => message)
      end

      it 'returns an error for the protected endpoint if the token has the wrong issuer' do
        message = 'Invalid issuer'
        error_struct = double(message: message, status: :unauthorized)
        response_struct = double(decoded_token: nil, error: error_struct)
        allow(JsonWebToken).to receive(:verify).and_return(response_struct)

        subject

        expect(response).to be_unauthorized
        expect(json_response!).to include('message' => message)
      end
    end
  
  # ...existing code...
end

For reasons of space, I'm grouping them, but to remain closer to the spirit of TDD, you should add them one by one. Note the tests are under a context called with error which will group the tests and make it more organized so you can go ahead and add a valid context for the missing test like so:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  # 👇 new code
  context 'valid' do 
    it 'returns an accepted answer for the protected endpoint' do
      allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))

      subject
      expect(response).to be_ok

      message = 'The API successfully validated your access token.'
      expect(json_response!).to include('message' => message)
    end
  end
  # 👆 new code

  # ...existing code...

If you look at the tests under the with error context, they look pretty similar to each other, except for the message string. This could be a hint for you to use a shared example.

Go ahead and create a new file under spec/support/shared and call it invalid_token.rb and fill it out with the following code:

RSpec.shared_examples 'invalid token' do |message|
  it message.to_s do
    error_struct = double(message: message, status: :unauthorized)
    response_struct = double(decoded_token: nil, error: error_struct)
    allow(JsonWebToken).to receive(:verify).and_return(response_struct)

    subject

    expect(response).to be_unauthorized
    expect(json_response!).to include('message' => message)
  end
end

You call the shared example an invalid token and pass a message argument. You are adding a test inside where the JsonWebToken.verify method returns a response with the error message set as the one in the argument. Finally, the test expects that the response is unauthorized and includes the same error message.

Note this test might look a bit weird. You are mocking the exception with a message and expecting the response to have the same message. This happens because this code is simple, and it doesn't add much functionality around the validation process other than calling the JWT.decode function. This code is meant to be used as an example of how to implement TDD and use the RSpec gem. For a real-life application, you'll probably have a much more complex codebase.

To use your new shared example, go to the messages_controller_spec.rb and add the following:

# spec/api/messages_controller_spec.rb

require 'rails_helper'

describe Api::MessagesController, type: :controller do
  describe '#protected' do
      subject { get :protected, params: { format: :json } }

      # 👇 new code
      context "with error" do 
        include_examples "invalid token", "Invalid audience"
        include_examples "invalid token", "Nil JSON web token"
        include_examples "invalid token", "Signature has expired"
        include_examples "invalid token", "Invalid issuer"
      end
      # 👆 new code
      
      # ✨ You can delete the following code! 👇
      # 
      # context 'with error' do 
      #   it 'returns an error for the protected endpoint if the token has the wrong audience' do
      #     message = 'Invalid audience'
      #     error_struct = double(message: message, status: :unauthorized)
      #     response_struct = double(decoded_token: nil, error: error_struct)
      #     allow(JsonWebToken).to receive(:verify).and_return(response_struct)

      #     subject

      #     expect(response).to be_unauthorized
      #     expect(json_response!).to include('message' => message)
      #   end

      #   it 'returns an error for the protected endpoint if there is no token' do
      #     message = 'Nil JSON web token'
      #     error_struct = double(message: message, status: :unauthorized)
      #     response_struct = double(decoded_token: nil, error: error_struct)
      #     allow(JsonWebToken).to receive(:verify).and_return(response_struct)

      #     subject

      #     expect(response).to be_unauthorized
      #     expect(json_response!).to include('message' => message)
      #   end

      #   it 'returns an error for the protected endpoint if the token is expired' do
      #     error_struct = double(message: message, status: :unauthorized)
      #     response_struct = double(decoded_token: nil, error: error_struct)
      #     allow(JsonWebToken).to receive(:verify).and_return(response_struct)

      #     subject

      #     expect(response).to be_unauthorized
      #     expect(json_response!).to include('message' => message)
      #   end

      #   it 'returns an error for the protected endpoint if the token has the wrong issuer' do
      #     message = 'Invalid issuer'
      #     error_struct = double(message: message, status: :unauthorized)
      #     response_struct = double(decoded_token: nil, error: error_struct)
      #     allow(JsonWebToken).to receive(:verify).and_return(response_struct)

      #     subject

      #     expect(response).to be_unauthorized
      #     expect(json_response!).to include('message' => message)
      #   end
      # end

      # ... existing code 
  end
end

Implementing authorization

To implement the authorization, you're using a before_action callback. You pick the token from the header and verify it with the library. Traditionally, this helper method goes in the ApplicationController, the base class for all controllers. Here is the code:

# app/controllers/application_controller.rb

require 'json_web_token'

class ApplicationController < ActionController::API
  def authorize!
    token = raw_token(request.headers)
    validation_response = JsonWebToken.verify(token)

    @token ||= validation_response.decoded_token

    return unless (error = validation_response.error)

    render json: { message: error.message }, status: error.status
  end

  private

  def raw_token(headers)
    return headers['Authorization'].split.last if headers['Authorization'].present?
  end
end

And you ensure it's used only for the routes you want:

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    before_action :authorize!, except: %i[public]
  end
end

Now you see the tests passing:

TODO: Update this image

Testing Permissions

At this point, you've got the protected route covered. Now, we shall focus on the admin route. The next step is checking for the token's correct permission claim. Your regular token doesn't have the claim, so you'll stub it to be a valid token but not add permissions to it. The test should fail:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  describe '#admin' do
    subject { get :admin, params: { format: :json } }

    it 'returns an error for the admin endpoint if the token does not have permissions' do
      allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: {valid: :token, 'permissions' => ''}, error: nil))
    
      subject
      expect(response).to be_unauthorized
      expect(json_response!['message']).to include('Access is denied')
    end
  end
  
  # ...existing code...
end

Now, let's implement the permission check in the controller:

# app/controllers/application_controller.rb
require 'json_web_token'

class ApplicationController < ActionController::API
  # ...existing code...
  
  def can_read_admin_messages!
    check_permissions(@token, 'read:admin-messages')
  end
  
  def check_permissions(token, permission)
    permissions = token['permissions'] || []
    permissions = permissions.split if permissions.is_a? String
  
    unless permissions.include?(permission)
      render json: { message: 'Access is denied' }.to_json,
              status: :unauthorized
    end
  end

  
  # ...existing code...
end

To make sure the check is applied to the correct route, you follow a similar approach to before, based on a before_action:

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    # ...existing code...
    
    before_action :can_read_admin_messages!, only: %i[admin]
end

Now you can run the tests to make sure everything works as expected.

You Got There!

Writing code following TDD is all about having a tight feedback loop. This isn't easy to show in writing due to many small changes, but I hope I have given you a good glimpse.

Using TDD brings several benefits:

  • You've confirmed that the implementation, you know, works
  • The test suite describes the application's behavior in an executable way instead of potentially misleading documentation
  • You're incorporating testability into your code from the beginning
  • Often, we tend to build things that we might need for a future that never comes. If you encode your expectations as tests, that becomes more visible, and it helps prevent it from happening.

Perhaps not every line of code you write needs to be covered by tests. Nevertheless, a focus on testability helps to deliver better products more reliably.

You can download the sample project shown in this article from this GitHub repository.