Packages

A configurable Plug middleware to quickly find errors that might prevent a deployment. It can also be used as an application health-check.

Current section

Files

Jump to
smoke_test lib smoke_test.ex
Raw

lib/smoke_test.ex

defmodule SmokeTest.Test do
defstruct [:id, :task, :timeout, :result]
end
defmodule SmokeTest do
@moduledoc """
A configurable Plug middleware to quickly find errors that might prevent a
deployment. It can also be used as an application health-check.
## Usage
SmokeTest can be easily configured for Plug or Phoenix servers. The only
options you need to provide is the OTP application that contains your
smoketest configuration.
### Plug
For pure Plug applications, simply use `Plug.Router.forward/2` on the
path you'd like to use for smoke-testing.
```
defmodule ExamplePlugWeb.Router do
use Plug.Router
forward "/ping", to: SmokeTest, init_opts: [otp_app: :example])
end
```
### Phoenix
For Phoenix applications, adding SmokeTest to your router is also a one-liner.
You can pass Smoketest into [`Phoenix.Router.forward/4`](https://hexdocs.pm/phoenix/Phoenix.Router.html#forward/4), choosing
whatever route you prefer for smoke testing.
```
defmodule ExamplePhoenixWeb.Router do
use ExampleWeb, :router
forward "/ping", SmokeTest, [otp_app: :example]
end
```
### Registering Tests
Tests are added as part of your mix configuration. Configuration expects an a map with the following properties:
- `id` the id of the test.
- `test` either a three item tuple of `{ Module, :fun, [args]}` or an anonymous function.
- `timeout` (optional) the amount of time (in ms) the test has to complete. Defaults to 1000.
Each test should return a two-item tuple of `{:ok, term}` or `{:error, reason}`. Items that
don't fulfill this spec are marked as a failure.
```
config :example, SmokeTest,
tests: [
# Test "db" calls a Module.function(args), with a timeout of 5000 ms
%{ id: "db", test: {Module, :function, [args]}, timeout: 5000} ,
# Test "cluster" calls an anonymous function, with a timeout of 1500ms
%{ id: "cluster", test: fn -> :net_adm.names() end, timeout: 1500 },
# Test "other" calls another module, and uses the default timeout of 1000
%{ id: "other", test: {Other.Module, :function, [args]} },
]
```
### Additional Configuration
#### Response Status
The status to return on success and failure is also configurable. Success
defaults to 200, and failure defaults to 503
```
config :example, Smoketest,
success_status: 201,
failure_status: 500
```
#### Json Encoder
A json encoder is required to diplay output. A `Poison` adapter is provided by
default, and need not be configured if present in your deps. See `SmokeTest.Adapters.JSONEncoder.Poison`
for more information.
To explicitly configure your own adapter, add the following to your `SmokeTest`
configuration:
```
config :example, SmokeTest,
json_encoder: SmokeTest.Adapters.JSONEncoder.Poison # Again, used by default.
```
## Example JSON Output
Here's a quick example output of a failed smoketest.
```
{
"app":"example",
"status":"failures",
"version":"0.0.1",
"timeouts":[
{
"id": "cluster"
"result": "No response",
"timeout": 15000
}
],
"failures":[
{
"id":"DB"
"timeout":5000,
"result":"Argument Error"
}
],
}
```
And a successful one
```
{
"app":"example",
"status":"ok",
"version":"0.0.1",
}
```
## Encoders
The `Poison` JSON encoder is included with this module. If present, the
module will automatically include the adapter for you. See `SmokeTest.Adapters.JSONEncoder`
and `SmokeTest.Adapters.JSONEncoder.Poison` for more details.
"""
import Plug.Conn
alias SmokeTest.Adapters.JSONEncoder
@behaviour Plug
@default_encoder if Code.ensure_loaded?(Poison), do: JSONEncoder.Poison, else: nil
def init(opts), do: opts
def call(_conn, []), do: raise otp_app_error()
def call(conn, [otp_app: otp_app]) do
config = Application.get_env(otp_app, __MODULE__, [])
encoder = Keyword.get(config, :json_encoder, @default_encoder) || raise decoder_error()
tests = Keyword.get(config, :tests, [])
success_status = Keyword.get(config, :success_status, 200)
failure_status = Keyword.get(config, :failure_status, 503)
project = Mix.Project.config()
results =
tests
|> Enum.map(&run_test/1)
|> Enum.map(&await_tests/1)
|> Enum.group_by(
fn
%SmokeTest.Test{result: {:ok, _ }} -> :ok
%SmokeTest.Test{result: {:timeout, _}} -> :timeouts
%SmokeTest.Test{result: {:error, _}} -> :failures
_ -> :failures
end,
fn
%{result: {_, result}} = test ->
%{ id: test.id, result: result, timeout: test.timeout }
test ->
%{ id: test.id, result: inspect(test.result), timeout: test.timeout }
end
)
|> Map.delete(:ok)
{status_code, status_text} = format_status(results, success_status, failure_status)
body = Map.merge(results, %{
status: status_text,
app: project[:app],
version: project[:version]
})
conn
|> put_resp_content_type("application/json")
|> send_resp(status_code, encoder.encode!(body))
end
defp format_status(%{failures: _}, _, status), do: { status, "failures" }
defp format_status(%{timeouts: _}, _, status), do: { status, "failures" }
defp format_status(_, status, _), do: { status, "ok"}
defp run_test(%{id: id, test: fun, timeout: timeout}) when is_function(fun) do
%SmokeTest.Test{ id: id, task: Task.async(fun), timeout: timeout }
end
defp run_test(%{id: id, test: {mod, fun, args}, timeout: timeout}) do
%SmokeTest.Test{ id: id, task: Task.async(mod,fun,args), timeout: timeout }
end
defp run_test(%{id: id, test: fun}) do
run_test(%{id: id, test: fun, timeout: 1000})
end
defp await_tests(test) do
result =
case Task.yield(test.task, test.timeout) || Task.shutdown(test.task) do
{:ok, result } -> result
{:exit, reason} -> {:error, reason}
nil -> {:timeout, "No response"}
end
Map.put(test, :result, result)
end
# Error messages
defp decoder_error do
"""
No JSON encoder specified in configuration, and default JSON encoder is
unavailable. Please include a JSON encoder adapter. See hexdocs for details.
"""
end
defp otp_app_error do
"No OTP app specified in options. Please specify an application holding the SmokeTest configuration."
end
end