Skip to content

abstract struct Athena::Framework::Spec::APITestCase
inherits Athena::Framework::Spec::WebTestCase #

A WebTestCase implementation with the intent of testing API controllers. Can be extended to add additional application specific configuration, such as setting up an authenticated user to make the request as.

Usage#

Say we want to test the following controller:

class ExampleController < ATH::Controller
  @[ATHA::QueryParam("negative")]
  @[ARTA::Get("/add/{value1}/{value2}")]
  def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32
    sum = value1 + value2
    negative ? -sum : sum
  end
end

We can define a struct inheriting from self to implement our test logic:

struct ExampleControllerTest < ATH::Spec::APITestCase
  def test_add_positive : Nil
    self.get("/add/5/3").body.should eq "8"
  end

  def test_add_negative : Nil
    self.get("/add/5/3?negative=true").body.should eq "-8"
  end
end

The #request method is used to make our requests to the API, then we run are assertions against the resulting HTTP::Server::Response. A key thing to point out is that there is no HTTP::Server involved, thus resulting in more performant specs.

Tip

Checkout the built in expectations to make testing easier.

Attention

Be sure to call Athena::Spec.run_all to your spec_helper.cr to ensure all test case instances are executed.

Mocking External Dependencies#

The previous example was quite simple. However, most likely a controller is going to have dependencies on various other services; such as an API client to make requests to a third party API. By default each test will be executed with the same services as it would normally, i.e. those requests to the third party API would actually be made. To solve this we can create a mock implementation of the API client and make it so that implementation is injected when the test runs.

# Create an example API client.
@[ADI::Register]
class APIClient
  def fetch_latest_data : String
    # Assume this method actually makes an `HTTP` request to get the latest data.
    "DATA"
  end
end

# Define a mock implementation of our APIClient that does not make a request and just returns mock data.
class MockAPIClient < APIClient
  def fetch_latest_data : String
    # This could also be an instance variable that gets set when this mock is created.
    "MOCK_DATA"
  end
end

# Enable our API client to be replaced in the service container.
class ADI::Spec::MockableServiceContainer
  # Use the block version of the `property` macro to use our mocked client by default, while still allowing it to be replaced at runtime.
  #
  # The block version of `getter` could also be used if you don't need to set it at runtime.
  # The `setter` macro could be also if you only want to allow replacing it at runtime.
  property(api_client) { MockAPIClient.new }
end

@[ADI::Register]
class ExampleServiceController < ATH::Controller
  def initialize(@api_client : APIClient); end

  @[ARTA::Post("/sync")]
  def sync_data : String
    # Use the injected api client to get the latest data to sync.
    data = @api_client.fetch_latest_data

    # ...

    data
  end
end

struct ExampleServiceControllerTest < ATH::Spec::APITestCase
  def initialize
    super

    # Our API client could also have been replaced at runtime;
    # such as if you wanted provide it what data it should return on a test by test basis.
    # self.client.container.api_client = MockAPIClient.new
  end

  def test_sync_data : Nil
    self.post("/sync").body.should eq %("MOCK_DATA")
  end
end

Tip

See ADI::Spec::MockableServiceContainer for more details on mocking services.

Each test_* method has its own service container instance. Any services that are mutated/replaced within the initialize method will affect all test_* methods. However, services can also be mutated/replaced within specific test_* methods to scope it that particular test; just be sure that you do it before calling #request.

Constructors#

Methods#

#client : ATH::Spec::HTTPBrowser#

Returns a reference to the AbstractBrowser being used for the test.

View source

#delete(path : String, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a DELETE request to the provided path, optionally with the provided headers.

View source

#get(path : String, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a GET request to the provided path, optionally with the provided headers.

View source

#head(path : String, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a HEAD request to the provided path, optionally with the provided headers.

View source

#link(path : String, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a LINK request to the provided path, optionally with the provided headers.

View source

#patch(path : String, body : String | Bytes | IO | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a PATCH request to the provided path, optionally with the provided body and headers.

View source

#post(path : String, body : String | Bytes | IO | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a POST request to the provided path, optionally with the provided body and headers.

View source

#put(path : String, body : String | Bytes | IO | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a PUT request to the provided path, optionally with the provided body and headers.

View source

#request(method : String, path : String, body : String | Bytes | IO | Nil = nil, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

View source

#request(request : HTTP::Request | ATH::Request) : HTTP::Server::Response#

View source

#unlink(path : String, headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Server::Response#

Makes a UNLINK request to the provided path, optionally with the provided headers.

View source