π§ͺ Nonnative
Nonnative is a Ruby-first harness for end-to-end testing of systems implemented in other languages.
It helps you:
- start OS processes (e.g. your Go/Java/Rust service binary),
- start in-process Ruby servers (e.g. small HTTP/TCP/gRPC fakes for dependencies),
- optionally start service proxies for fault-injection in front of externally managed dependencies,
- wait for readiness/shutdown using TCP port checks, optional process HTTP/gRPC checks, and optional service TCP checks.
Once started, you can test however you like (TCP, HTTP, gRPC, etc).
π¦ Installation
[!IMPORTANT] Nonnative currently supports Ruby
>= 4.0.0and< 5.0.0.
Add this line to your application's Gemfile:
gem 'nonnative'
And then execute:
bundle
Or install it yourself as:
gem install nonnative
π οΈ Contributor Bootstrap
Fresh clones need the shared bin/ submodule before Make targets can load:
git submodule sync && git submodule update --init
make help
Use make dep before local validation when dependencies are missing. The CI-parity checks are
make lint, make sec, make features, and make benchmarks.
π Usage
Nonnative is configured via Nonnative.configure (programmatic) or config.load_file(...) (YAML).
YAML configuration is loaded as data only: ERB is not evaluated and arbitrary Ruby objects are not
deserialized.
[!CAUTION] Treat YAML configuration as plain data. ERB is not evaluated and arbitrary Ruby object tags are rejected.
High-level configuration fields:
version: configuration version (example:"1.0").name: logical system name (used byNonnative.observabilityfor/<name>/healthz, etc).url: base URL for observability queries (example:http://localhost:4567).log: path for the Nonnative logger output.processes: child processes tospawn.servers: in-process Ruby servers started in threads.services: external dependencies (no process/thread started by Nonnative).
Common runner fields:
name: runner name used for lookup.host: client-facing host. Defaults to127.0.0.1.
Process/server fields:
ports: client-facing ports. These are also used for readiness/shutdown port checks.timeout: max time (seconds) for readiness/shutdown port checks. Defaults to1.0.wait: small sleep (seconds) between lifecycle steps.log: per-runner log file used by process output redirection or server implementations.
Process-only fields:
readiness: optional list of startup readiness checks. Supported kinds arehttpandgrpc. HTTP checks require explicitportand path-onlypath. gRPC checks require explicitportand healthservice.
Service fields:
port: client-facing service port.timeout: max time (seconds) for opt-in service readiness checks. Defaults to1.0.readiness: optional list of startup readiness checks. Supported kind istcp, which requires explicithostandport.
Nonnative readiness and shutdown checks are TCP port checks by default. Configure process/server ports that are dedicated to the test run; if another process is already listening on the same endpoint, results are undefined. Processes can also opt into HTTP and gRPC readiness checks that run after TCP readiness succeeds. Services do not get automatic TCP readiness/shutdown checks, but can opt into TCP startup readiness for externally managed dependencies. HTTP readiness paths must be path-only values, such as /test/readyz; absolute URLs and scheme-relative URLs are rejected.
[!WARNING] TCP readiness and shutdown checks only prove that a TCP port opened or closed. HTTP and gRPC readiness are process-only. Service readiness is TCP-only and should target the dependency endpoint that must be reachable before managed servers/processes start.
Start and stop Nonnative around the test scope that should own the configured runners:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
Nonnative.start
# run tests...
Nonnative.stop
Nonnative.start starts services first, then servers and processes. Nonnative.stop stops processes and servers first, then services. If startup fails, Nonnative rolls back runners that already started and raises Nonnative::StartError; shutdown failures raise Nonnative::StopError.
[!NOTE]
Nonnative.start/Nonnative.stopmanage one lifecycle for the current pool. CallNonnative.clearbefore reconfiguring Nonnative or starting a new lifecycle in the same Ruby process.Nonnative.clearclears memoized configuration, logger, observability client, and pool.
π§© Test framework setup
For Cucumber, load and configure Nonnative in features/support/env.rb, then use lifecycle tags on scenarios:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
@startup
Scenario: run with Nonnative around this scenario
For RSpec or another suite that should start Nonnative once per test run, configure first and then require the startup integration:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
require 'nonnative/startup'
nonnative/startup calls Nonnative.start immediately and registers an at_exit stop, so load configuration before requiring it.
π Observability
Nonnative.observability is an HTTP client for common service endpoints under the configured name and url:
health(...): calls/<name>/healthz.liveness(...): calls/<name>/livez.readiness(...): calls/<name>/readyz.metrics(...): calls/<name>/metrics.
Each method accepts RestClient options such as headers, open_timeout, and read_timeout.
response = Nonnative.observability.health(
headers: { content_type: :json, accept: :json },
open_timeout: 2,
read_timeout: 2
)
expect(response.code).to eq(200)
Nonnative.grpc_health is a helper for the standard gRPC health checking protocol:
health = Nonnative.grpc_health(
host: '127.0.0.1',
port: 12_322,
service: 'example.v1.ExampleService',
timeout: 2
)
expect(health.serving?).to eq(true)
π Tokens
Nonnative.token builds a signer for authenticating requests against a service under test. You pass the signing parameters directly (parsed from your own configuration); it is not coupled to any service's config format. The generated string is ready for Nonnative::Header.auth_bearer.
token = Nonnative.token(
kind: 'jwt',
issuer: 'iss',
key: 'key-1',
private_key: 'config/ed25519.pem',
expiration: 3600
)
headers = Nonnative::Header.auth_bearer(
token.generate(aud: Nonnative::Token.http_audience('GET', '/v1/things'), sub: 'user-1')
)
Supported kind values (all Ed25519, generation only):
jwt: EdDSA JWT with the key id in thekidheader.private_keyis a PKCS#8 PEM file.paseto: PASETO v4.public with the key id in a{"kid":"..."}footer.private_keyis a PKCS#8 PEM file. Requires system libsodium (viarbnacl); it loads lazily, sorequire 'nonnative'works without libsodium until you generate a PASETO token.ssh: go-service stylebase64(claims).base64(signature)with a raw Ed25519 signature overv1claims.private_keyis an OpenSSH-format key.issuerandsubare ignored (the subject is the key id).
The audience is endpoint-scoped; build it with the helpers:
Nonnative::Token.http_audience('GET', '/v1/things') # => "GET /v1/things"
Nonnative::Token.grpc_audience('/health.v1.Health/Check') # => "/health.v1.Health/Check"
π Lifecycle strategies (Cucumber integration)
Nonnative ships Cucumber hooks (when loaded) that support these tags/strategies:
@startup: start before scenario; stop after scenario@manual: stop after scenario; useWhen I start the systemto start manually@clear: clears memoized configuration, logger, observability client, and pool before scenario@reset: resets proxies after scenario
The repoβs own Cucumber suite also uses taxonomy tags to classify coverage:
@acceptance: end-to-end behavior across configured runners and clients@contract: lower-level contract and lifecycle behavior@proxy: proxy-specific behavior and failure injection@config: coverage that exercises YAML/config loading@service: scenarios centered on externally managed dependencies@benchmark: benchmark-only scenarios run bymake benchmarks@slow: slower scenarios, currently used by benchmark coverage
make features excludes @benchmark, while make benchmarks runs only @benchmark.
Requiring nonnative is enough; the Cucumber hooks and step definitions are installed lazily once Cucumberβs Ruby DSL is ready.
βοΈ Processes
A process is some sort of command that you would run locally.
Programmatic p.command values must be callables that return a shell string or an argv array. YAML command values can be scalars or lists and are wrapped internally. String commands preserve legacy shell semantics, while argv arrays avoid shell interpretation and are preferred for new configuration.
[!TIP] Prefer argv arrays for new process commands. Use shell strings only when you intentionally need shell parsing, expansion, or redirection.
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.process do |p|
p.name = 'start_1'
p.command = -> { ['bin/start-test-service', '12_321'] }
p.timeout = 5
p.wait = 0.1
p.ports = [12_321]
p.log = '12_321.log'
p.signal = 'INT' # Possible values are described in Signal.list.keys.
p.readiness = [
{ kind: 'http', port: 12_321, path: '/test/readyz' },
{ kind: 'grpc', port: 12_322, service: 'example.v1.ExampleService' }
]
p.environment = { # Pass environment variables to process.
'TEST' => 'true'
}
end
config.process do |p|
p.name = 'start_2'
p.command = -> { ['bin/start-test-service', '12_322'] }
p.timeout = 0.5
p.wait = 0.1
p.ports = [12_322]
p.log = '12_322.log'
end
end
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
processes:
-
name: start_1
command:
- bin/start-test-service
- "12_321"
timeout: 5
wait: 1
ports:
- 12321
log: 12_321.log
signal: INT # Possible values are described in Signal.list.keys.
readiness:
- kind: http
port: 12321
path: /test/readyz
- kind: grpc
port: 12322
service: example.v1.ExampleService
environment: # Pass environment variables to process.
TEST: true
-
name: start_2
command:
- bin/start-test-service
- "12_322"
timeout: 5
wait: 1
ports:
- 12322
log: 12_322.log
Then load the file with
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
With cucumber you can also verify how much memory is used by the process:
Then the process 'start_1' should consume less than '25mb' of memory
π₯οΈ Servers
A server is an in-process Ruby fake or helper server that Nonnative starts in a thread. Use servers for dependencies that are easiest to model inside the test process, such as small TCP, HTTP, or gRPC fakes.
Define your server:
module Nonnative
class TCPServer < Nonnative::Server
def initialize(service)
super
@socket_server = ::TCPServer.new(service.host, service.port)
end
def perform_start
loop do
client_socket = socket_server.accept
client_socket.puts 'Hello World!'
client_socket.close
end
rescue StandardError
socket_server.close
end
def perform_stop
socket_server.close
end
private
attr_reader :socket_server
end
end
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.server do |s|
s.name = 'server_1'
s.klass = Nonnative::TCPServer
s.timeout = 1
s.ports = [12_323]
s.log = 'server_1.log'
end
config.server do |s|
s.name = 'server_2'
s.klass = Nonnative::TCPServer
s.timeout = 1
s.ports = [12_324]
s.log = 'server_2.log'
end
end
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
servers:
-
name: server_1
class: Nonnative::TCPServer
timeout: 1
ports:
- 12323
log: server_1.log
-
name: server_2
class: Nonnative::TCPServer
timeout: 1
ports:
- 12324
log: server_2.log
Then load the file with:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
π HTTP
Define your server:
module Nonnative
module Features
class HelloService < Nonnative::HTTPService
get '/hello' do
'Hello World!'
end
end
class HTTPServer < Nonnative::HTTPServer
def initialize(service)
super(HelloService.new, service)
end
end
end
end
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.server do |s|
s.name = 'http_server_1'
s.klass = Nonnative::Features::HTTPServer
s.timeout = 1
s.ports = [4567]
s.log = 'http_server_1.log'
end
end
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
servers:
-
name: http_server_1
class: Nonnative::Features::HTTPServer
timeout: 1
ports:
- 4567
log: http_server_1.log
Then load the file with:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
π HTTP Forward Proxy
The system allows you to define an in-process HTTP forward proxy server for external systems, e.g. api.github.com. This is a server implementation, not a fault-injection service proxy.
Define your server:
module Nonnative
module Features
class HTTPProxyServer < Nonnative::HTTPProxyServer
def initialize(service)
super('www.afalkowski.com', service)
end
end
end
end
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.server do |s|
s.name = 'http_server_proxy'
s.klass = Nonnative::Features::HTTPProxyServer
s.timeout = 1
s.ports = [4567]
s.log = 'http_server_proxy.log'
end
end
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
servers:
-
name: http_server_proxy
class: Nonnative::Features::HTTPProxyServer
timeout: 1
ports:
- 4567
log: http_server_proxy.log
Then load the file with:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
π‘ gRPC
Define your server:
Assume the gRPC service type and response types below come from your generated gRPC stubs.
module Nonnative
module Features
class Greeter < GreeterService::Service
def say_hello(request, _call)
Nonnative::Features::SayHelloResponse.new(message: request.name.to_s)
end
end
class GRPCServer < Nonnative::GRPCServer
def initialize(service)
super(Greeter.new, service)
end
end
end
end
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.server do |s|
s.name = 'grpc_server_1'
s.klass = Nonnative::Features::GRPCServer
s.timeout = 1
s.ports = [9002]
s.log = 'grpc_server_1.log'
end
end
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
servers:
-
name: grpc_server_1
class: Nonnative::Features::GRPCServer
timeout: 1
ports:
- 9002
log: grpc_server_1.log
The grpc gem uses a global logger, so per-server gRPC log files are not independent. The first
initialized gRPC server sets the logger used by later gRPC servers in the same Ruby process.
Then load the file with:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
π§© Services
A service is an external dependency to your system that you do not want Nonnative to start (no OS process, no Ruby thread).
Services do not get process lifecycle management or automatic TCP readiness/shutdown checks from Nonnative. They provide a named endpoint for a dependency that another tool already manages, and can opt into TCP startup readiness when the dependency must be reachable before managed servers/processes start.
Set it up programmatically:
require 'nonnative'
Nonnative.configure do |config|
config.version = '1.0'
config.name = 'test'
config.url = 'http://localhost:4567'
config.log = 'nonnative.log'
config.service do |s|
s.name = 'postgres'
s.host = '127.0.0.1'
s.port = 5432
s.timeout = 5
s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 5432 }]
end
config.service do |s|
s.name = 'redis'
s.host = '127.0.0.1'
s.port = 6379
end
end
Set it up through configuration (YAML):
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
services:
-
name: postgres
host: 127.0.0.1
port: 5432
timeout: 5
readiness:
- kind: tcp
host: 127.0.0.1
port: 5432
-
name: redis
host: 127.0.0.1
port: 6379
Then load the file with:
require 'nonnative'
Nonnative.configure do |config|
config.load_file('configuration.yml')
end
πΈοΈ Proxies
These proxies can simulate different situations. Available proxy kinds are:
none(this is the default)fault_injection
[!WARNING] Unknown proxy kinds raise an error. If fault injection is not taking effect, check the
kindspelling or register the custom kind before starting the system.
Custom proxy kinds can be registered through Nonnative.proxies:
Nonnative.proxies['custom'] = CustomProxy
Only services support proxies. For fault_injection, keep the service host/port as the client-facing proxy endpoint and use nested proxy.host/proxy.port for the upstream target behind the proxy.
When service readiness is configured for a proxied dependency, set the readiness host/port to the upstream dependency, not the client-facing proxy listener.
π§© Service Proxies
Programmatic Configuration
Add a proxy to a service configuration:
config.service do |s|
s.name = 'redis'
s.host = '127.0.0.1'
s.port = 16_379
s.proxy = {
kind: 'fault_injection',
host: '127.0.0.1',
port: 6379,
log: 'proxy_server.log',
wait: 1,
options: {
delay: 5
}
}
s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 6379 }]
end
YAML Configuration
Add a proxy to a service YAML entry:
services:
-
name: redis
host: 127.0.0.1
port: 16379
readiness:
- kind: tcp
host: 127.0.0.1
port: 6379
proxy:
kind: fault_injection
host: 127.0.0.1
port: 6379
log: proxy_server.log
wait: 1
options:
delay: 5
π§ͺ Fault Injection
The fault_injection proxy allows you to simulate failures by injecting them. We currently support the following:
Clients connect to the service host/port, while the proxy forwards traffic to nested proxy.host/proxy.port.
close_all- Closes the socket as soon as it connects.delay- Delays traffic on the connection. Defaults to 2 seconds and can be configured through options.timeout- Accepts the connection and stalls traffic until reset or stop closes the connection, so clients exercise their own read timeout behavior.invalid_data- Forwards client requests unchanged, then corrupts upstream responses before they reach the client.
π§© Fault Injection Services
Set the proxy state programmatically:
name = 'name of service in configuration'
service = Nonnative.pool.service_by_name(name)
service.proxy.close_all # To use close_all.
service.proxy.timeout # To stall traffic until reset or stop.
service.proxy.reset # To reset it back to a good state.
Use the Cucumber proxy steps:
Given I set the proxy for service 'service_1' to 'close_all'
Given I set the proxy for service 'service_1' to 'timeout'
Then I should reset the proxy for service 'service_1'
πΉ Go
As we love using Go as a language for services we have added support to start binaries with defined parameters.
Programmatic Go binaries can be configured as normal argv process commands:
Nonnative.configure do |config|
config.process do |p|
p.name = 'go'
p.command = -> { Nonnative.go_argv(%w[cover], 'reports', 'your_binary', 'sub_command', '-i file:.config/server.yml') }
p.ports = [12_345]
end
end
Use Nonnative.go_argv(...) when a process should execute without shell interpretation, and Nonnative.go_command(...) when a caller needs a command string for Ruby's shell-style spawn behavior.
YAML go: configuration is for Go test binaries compiled with go test -c. It builds argv entries in this order: executable, optional -test.* profiling/trace/coverage flags, command, then parameters. Parameter strings are parsed into argv words with shell-style quoting, but the argv entries are executed without shell interpretation.
[!IMPORTANT] If
toolsis omitted or empty, Nonnative enables all Go tools:prof,trace, andcover. Provide a subset, such astools: [cover], to limit the generated-test.*flags.
To get this to work you will need to create a main_test.go file with these contents:
//go:build features
// +build features
package main
import "testing"
func TestFeatures(t *testing.T) {
main()
}
Then to compile this binary you will need to do the following:
go test -mod vendor -c -tags features -covermode=count -o your_binary -coverpkg=./... github.com/your_location
Set it up through configuration:
version: "1.0"
name: test
url: http://localhost:4567
log: nonnative.log
processes:
-
name: go
go:
tools: [prof, trace, cover]
output: reports
executable: your_binary
command: sub_command
parameters:
- "-i file:.config/server.yml"
timeout: 5
ports:
- 8000
log: go.log