Repository navigation
Replies: 3 comments
Revision: real files, and a self-contained humustestThis revision applies the decisions in the lifecycle proposal's revision:
Each section below replaces the section of the same name in the post. Background, Out of scope and References are unchanged and still apply. Summaryhumus is being rebuilt on the process model so that a whole service can run inside a test. humustest supplies everything a test needs:
With it, every humus App runs in a Proposed APIpackage humustest
// Host is a humus.Host for tests: a network of net.Pipe connections, and a
// filesystem in a directory from t.TempDir(). The network starts no
// goroutines, so the Host works inside a testing/synctest bubble; build it
// inside the bubble that uses it.
type Host struct{ /* unexported */ }
type HostOption func(*Host)
// WithFiles writes files into the Host's directory, keyed by their path
// relative to it, such as "etc/tls/server.p12". Parent directories are
// created.
func WithFiles(files map[string][]byte) HostOption
// NewHost returns a Host whose directory comes from t.TempDir(), so it is
// removed when the test ends.
func NewHost(t testing.TB, opts ...HostOption) *Host
// Host returns the humus.Host to run an App on, like bedrocktest's
// Process.Process.
func (h *Host) Host() humus.Host
// Listen listens on the port of address. The host part is ignored, and
// port 0 picks a free port. A port already in use fails with EADDRINUSE.
func (h *Host) Listen(network, address string) (net.Listener, error)
// Dial connects to the listener on the port of address. It fails with
// ECONNREFUSED when nothing listens there.
func (h *Host) Dial(ctx context.Context, network, address string) (net.Conn, error)
// FS returns the Host's filesystem. It is confined to the Host's directory
// with os.Root, so ".." and symlinks can't leave it. It accepts absolute
// paths under that directory, so a file's Name() can be passed back in, as
// in production. CreateTemp and MkdirTemp with dir == "" use a directory
// inside it.
func (h *Host) FS() humus.FS
// Path returns the absolute OS path of name, which is relative to the
// Host's directory, to put in a setting.
func (h *Host) Path(name string) string
// HTTPClient returns an http.Client that dials the Host, with keep-alives
// off, so no idle connection outlives a request.
func (h *Host) HTTPClient() *http.Client
// Telemetry records everything an App emits, using the real SDK with
// in-memory exporters.
type Telemetry struct{ /* unexported */ }
func NewTelemetry() *Telemetry
func (t *Telemetry) Builder() telemetry.Builder
func (t *Telemetry) Spans() []sdktrace.ReadOnlySpan
func (t *Telemetry) Metrics(ctx context.Context) (metricdata.ResourceMetrics, error)
func (t *Telemetry) Logs() []sdklog.Record
// Process is the process an App runs on in a test. It wraps bedrocktest's
// Process, so a test imports humustest alone.
type Process struct{ /* unexported */ }
type ProcessOption func(*processOptions)
// WithArgs, WithEnv and WithStdin work like bedrocktest's options of the
// same names.
func WithArgs(args ...string) ProcessOption
func WithEnv(env map[string]string) ProcessOption
func WithStdin(r io.Reader) ProcessOption
// Start runs app on a new Process, in a new goroutine, and returns the
// Process. To use it with testing/synctest, call it inside the bubble.
func Start(t testing.TB, app bedrock.App, opts ...ProcessOption) *Process
// Notified, Signal, Stdout and Stderr work like bedrocktest's.
func (p *Process) Notified(sig os.Signal) <-chan struct{}
func (p *Process) Signal(sig os.Signal)
func (p *Process) Stdout() string
func (p *Process) Stderr() string
// Wait blocks until the App returns, and returns its status.
func (p *Process) Wait() bedrock.ExitStatus
// TestService runs the humus.Service contract against the Services that
// newService builds. A package that provides a Service calls it from its
// own tests.
func TestService(t *testing.T, newService func(t *testing.T, s *humus.Setup) humus.Service)
gRPC helpers, such as a What every package proposal must show
Design decisions
TestingWhole-App tests run on this API. These include the lifecycle prototype's network tests, which the lifecycle's own tests leave out. For example: func TestApp_AJobEndsTheAppAndStopsTheOtherServices(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
host := humustest.NewHost(t)
app := humus.App{
Host: host.Host(),
Telemetry: telemetry.NoopBuilder,
Init: func(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
probe, err := newHTTPService(s, ":8081", http.NotFoundHandler())
if err != nil {
return nil, err
}
work := job(func(ctx context.Context) error {
time.Sleep(time.Second)
return nil
})
return []humus.Service{probe, work}, nil
},
}
st := humustest.Start(t, app).Wait()
require.Equal(t, 0, st.Code())
require.NoError(t, st.Cause())
_, err := host.Dial(t.Context(), "tcp", "localhost:8081")
require.ErrorIs(t, err, syscall.ECONNREFUSED, "the probe server is closed with the App")
})
}Proposal: configuration for humus apps shows Migration from today
Open questionsQuestion 1 (
PrototypeThe prototype in the post predates this revision. Its network ( |
Revision 2: the open questions answeredThis revision answers every open question that the first revision left open, and reverses its answer to question 1:
Each section below replaces the section of the same name in the first revision. Background, Out of scope and References still apply. Summaryhumus is being rebuilt on the process model so that a whole service can run inside a test. bedrocktest supplies the process, and starts an App on it with
With both, every humus App runs in a Proposed APIpackage humustest
// Host is a humus.Host for tests: a network of net.Pipe connections, and a
// filesystem in a directory from t.TempDir(). The network starts no
// goroutines, so the Host works inside a testing/synctest bubble; build it
// inside the bubble that uses it.
type Host struct{ /* unexported */ }
type HostOption func(*Host)
// WithFiles writes files into the Host's directory, keyed by their path
// relative to it, such as "etc/tls/server.p12". Parent directories are
// created.
func WithFiles(files map[string][]byte) HostOption
// NewHost returns a Host whose directory comes from t.TempDir(), so it is
// removed when the test ends.
func NewHost(t testing.TB, opts ...HostOption) *Host
// Host returns the humus.Host to run an App on, like bedrocktest's
// Process.Process.
func (h *Host) Host() humus.Host
// Listen listens on the host and port of address. An empty host, 0.0.0.0
// or :: listens on every name. Port 0 picks a free port. Listen fails with
// EADDRINUSE when a listener already holds the same name and port, or
// when one of the two listens on every name for that port.
func (h *Host) Listen(network, address string) (net.Listener, error)
// Dial connects to the listener for the host and port of address: one
// bound to that exact name, or else one that listens on every name for
// that port. It fails with ECONNREFUSED when there is none. It never
// waits for a listener.
func (h *Host) Dial(ctx context.Context, network, address string) (net.Conn, error)
// Listening returns a channel that is closed once a listener exists that a
// Dial to address would reach. A test receives from it before dialling, so
// that it doesn't dial before the App listens. It works like bedrocktest's
// Process.Notified: once closed, the channel stays closed, and after that
// listener closes, Listening returns a new channel, closed when the next
// one is made. The channel is created on the first call, and no goroutine
// is started, so Listening works inside a testing/synctest bubble.
func (h *Host) Listening(address string) <-chan struct{}
// FS returns the Host's filesystem. It is confined to the Host's directory
// with os.Root, so ".." and symlinks can't leave it. It accepts absolute
// paths under that directory, so a file's Name() can be passed back in, as
// in production. CreateTemp and MkdirTemp with dir == "" use a directory
// inside it.
func (h *Host) FS() humus.FS
// Path returns the absolute OS path of name, which is relative to the
// Host's directory, to put in a setting.
func (h *Host) Path(name string) string
// HTTPClient returns an http.Client that dials the Host, with keep-alives
// off, so no idle connection outlives a request.
func (h *Host) HTTPClient() *http.Client
// CA is a certificate authority generated for one test. Its key, serial
// number and validity come from crypto/rand and the clock when NewCA is
// called. Nothing about it is fixed.
type CA struct{ /* unexported */ }
// NewCA generates a CA with a new random ECDSA P-256 key and a random
// 128-bit serial number. It is valid from an hour before time.Now() to a
// day after. Inside a synctest bubble that is the bubble's clock, so build
// the CA inside the bubble that uses it. NewCA fails the test through t
// if generation fails.
func NewCA(t testing.TB) *CA
// Pool returns a CertPool holding only the CA's certificate, for a
// client's tls.Config.RootCAs.
func (ca *CA) Pool() *x509.CertPool
// Issue returns a certificate for names, which are DNS names or IP
// literals, signed by the CA, with a new random key and serial number.
func (ca *CA) Issue(t testing.TB, names ...string) *Certificate
// Certificate is a leaf certificate issued by a CA.
type Certificate struct{ /* unexported */ }
// TLS returns the certificate and its chain, for a server's
// tls.Config.Certificates.
func (c *Certificate) TLS() tls.Certificate
// PKCS12 encodes the certificate, its key and the CA's certificate as a
// PKCS#12 bundle, encrypted with a new password from crypto/rand.Text.
// It returns the bundle and the password. Each call makes a new password.
func (c *Certificate) PKCS12(t testing.TB) (bundle []byte, password string)
// Telemetry records everything an App emits, using the real SDK with
// in-memory exporters.
type Telemetry struct{ /* unexported */ }
func NewTelemetry() *Telemetry
func (t *Telemetry) Builder() telemetry.Builder
func (t *Telemetry) Spans() []sdktrace.ReadOnlySpan
func (t *Telemetry) Metrics(ctx context.Context) (metricdata.ResourceMetrics, error)
func (t *Telemetry) Logs() []sdklog.Record
// TestService runs the humus.Service contract against the Services that
// newService builds. A package that provides a Service calls it from its
// own tests.
func TestService(t *testing.T, newService func(t *testing.T, s *humus.Setup) humus.Service)
func Start(tb testing.TB, app bedrock.App, opts ...bedrocktest.Option) (*bedrocktest.Process, <-chan bedrock.ExitStatus)The What every package proposal must show
Design decisions
TestingWhole-App tests start the App with func TestApp_AJobEndsTheAppAndStopsTheOtherServices(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
host := humustest.NewHost(t)
app := humus.App{
Host: host.Host(),
Telemetry: telemetry.NoopBuilder,
Init: func(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
probe, err := newHTTPService(s, ":8081", http.NotFoundHandler())
if err != nil {
return nil, err
}
work := job(func(ctx context.Context) error {
time.Sleep(time.Second)
return nil
})
return []humus.Service{probe, work}, nil
},
}
_, status := bedrocktest.Start(t, app)
st := <-status
require.Equal(t, 0, st.Code())
require.NoError(t, st.Cause())
_, err := host.Dial(t.Context(), "tcp", "localhost:8081")
require.ErrorIs(t, err, syscall.ECONNREFUSED, "the probe server is closed with the App")
})
}A TLS test generates everything it needs. Here func TestApp_ServesTLSFromAPKCS12File(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ca := humustest.NewCA(t)
bundle, password := ca.Issue(t, "api.internal").PKCS12(t)
host := humustest.NewHost(t, humustest.WithFiles(map[string][]byte{
"etc/tls/server.p12": bundle,
}))
fp, status := bedrocktest.Start(t, newApp(host), bedrocktest.WithEnv(map[string]string{
"HUMUS_HTTP_TLS_PKCS12_FILE": host.Path("etc/tls/server.p12"),
"HUMUS_HTTP_TLS_PKCS12_PASSWORD": password,
}))
synctest.Wait()
client := host.HTTPClient()
client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{RootCAs: ca.Pool()}
resp, err := client.Get("https://api.internal:8443/health")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
fp.Signal(syscall.SIGTERM)
require.Equal(t, 0, (<-status).Code())
})
}Migration from today
Open questionsNone. Questions 1 to 4 are answered above. Question 5 moves to Proposal: queue processing and the Kafka runtime. Question 6 is still settled by the charter's 80% gate. PrototypeThe first revision's note still holds, with one change: the prototype's |
Stories, and telemetry's ownershipTelemetry belongs to #632. Each proposal fully owns its domain, including how that domain integrates with other packages. So Stories
The stories settle three details the revisions left open:
#644 and #645 need |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Note
The Summary, Proposed API, design decisions, testing, migration, open questions and prototype in this post are superseded by the revision below. It applies the decisions of the lifecycle proposal, and records that this proposal owns humustest's integration with the lifecycle. The
Hostserves real files from the test's own temp directory, and humustest wraps bedrocktest so a test imports humustest alone. Open questions 1 and 6 are answered. The rest of this post is unchanged and kept for context.A second revision supersedes the same sections of the first. It answers the remaining open questions and reverses the answer to question 1.
Startmoves to bedrocktest (z5labs/bedrock#600), so humustest no longer wraps bedrocktest, and a test imports both. The network uses the host name in each address.Dialrefuses when nothing is listening, and tests wait for a listener withsynctest.WaitorHost.Listening. TLS keys, certificates and passwords are generated in each test. The Kafka question moves to the Kafka proposal.Note
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model. It depends on Proposal: the humus App lifecycle, which defines
HostandService. It also sets the testing requirements that every package proposal has to meet.Summary
humus is being rebuilt on the process model so that a whole service can run inside a test.
Host, for the network and files;Servicemust pass.With both, every humus App runs in a
testing/synctestbubble without a socket, a file, the real environment or real time.Background
bedrocktest builds a fake
ProcesswithNewProcess(t, WithArgs, WithEnv, WithStdin). It drives signals withNotifiedandSignal, and records output inStdoutandStderr. It is strict:Signalwith no live registration fails the test;NotifyContextthat is never stopped fails the test.It starts no goroutines, so it works inside
synctest(bedrocktest).bedrock's reference service test hand-rolls an in-memory listener over
net.Pipe(example/httpserver/app_test.go). Without humustest, every humus user would write the same one.humus tests today:
-tags testcontainers(queue/kafka/kafka_testcontainer.go), which needs docker;What the lifecycle prototype found:
synctest,net.Pipeand bedrocktest make shutdown tests deterministic: a 2s grace period ends after exactly 2s of fake time;synctestreports a deadlock when a goroutine the App started outlives the test body, so it doubles as a leak detector for Services.Proposed API
TestServicechecks, insidesynctest, that a Service obeys this contract:ServereturnsnilafterShutdown.Shutdownwith a context that is already done stops the Service at once and returns an error that wraps the context's error.Shutdowncalled beforeServe, andShutdowncalled twice, are both safe.Serve.gRPC helpers, such as a
grpc.DialOptionthat dials the Host, live with Proposal: gRPC services, so grpc-go stays out of the module graph of humus users who don't use gRPC.What every package proposal must show
synctestwith bedrocktest and humustest.t.Setenv, opens a real socket or touches the real filesystem, except integration tests behind a build tag.errors.Isorerrors.AsType, and stderr), not only the exit code.humustest.TestService.Design decisions
NewHost(opts...)and.Host()work likeNewProcess(t, opts...)and.Process().":8080", and a client dials"name:8080". Name resolution is not modelled.Dialfails with ECONNREFUSED when nothing listens, as a real dial does. A test callssynctest.Wait()after starting an App, which returns once the App is blocked inAccept.fs.FS. Settings carry absolute OS paths, andfs.FSrejects names that start with/.conformance_unix_test.godoes for signals.Testing
The lifecycle prototype's tests already use the
Hostbelow. For example:Migration from today
humustest.Host.kafka_testcontainer.gostays for integration tests behind the build tag. Unit tests move to whichever fake Proposal: queue processing and the Kafka runtime chooses (story(kafka): add unit tests using kfake #431).Open questions
Start's home. It only needsbedrock.App, so should it live in bedrocktest instead?Dialbefore a listener exists. Should it wait for one rather than refuse?testing/cryptotest). Should humustest provide a fixed test CA instead?synctest.kgo.Dialercan dial through the Host, but can kfake listen on it? If not, what is the unit-test story for the Kafka runtime?Out of scope
Prototype
This is the in-memory Host the lifecycle prototype runs on (see Proposal: the humus App lifecycle for its tests). It omits
Telemetry,StartandTestService.humustest/humustest.goReferences
example/httpserver/app_test.gotesting/synctestAll reactions