frp — Testing#

Test metrics#

  • Test files: 29 (*_test.go)
  • Source files: 263 (.go, excluding tests)
  • Ratio (test files / source files): ~11% — low, reflecting that most behavioral coverage lives in the E2E suite rather than unit tests
  • Test frameworks:
    • github.com/stretchr/testify/require — unit tests (all 28 non-E2E test files)
    • github.com/onsi/ginkgo/v2 + github.com/onsi/gomega — E2E suite under test/e2e/

Test organization#

  • Placement: Both strategies are used.
    • Most unit tests are same-package (e.g., client/config_manager_test.go declares package client), giving direct access to unexported fields for white-box assertions.
    • Some are _test package (e.g., pkg/config/v1/proxy_test.go declares package v1), treating the package as a black box.
  • Helper packages:
    • test/e2e/framework/ — full Ginkgo test framework: Framework struct handles temp directories, port allocation, mock server lifecycle, frps/frpc process management, Go-template config rendering, and cleanup actions.
    • test/e2e/mock/server/Server interface plus implementations: httpserver, streamserver (TCP/UDP/Unix echo), oidcserver.
    • test/e2e/pkg/ — supporting utilities: port.Allocator (range-partitioned port reservation), process.Process (subprocess management with stdout/stderr capture and WaitForOutput), request.Request (fluent HTTP/TCP test-request builder), cert (self-signed TLS cert generator), rpc, ssh/client.
  • Fixtures: No testdata/ directories observed. Config fixtures are inline Go string constants or built programmatically using Go text templates (e.g., consts.DefaultServerConfig + {{ .PortName }} placeholders rendered by the Framework).

Test patterns#

Table-driven tests#

  • Prevalence: Heavy in config and serialization packages; present but lighter in service/component packages.
  • Style: Anonymous struct slices (tests := []struct{ name string; content string }{...}) with t.Run(test.name, func(t *testing.T) { ... }).
  • Example: pkg/config/load_test.go:61TestLoadServerConfig runs the same parse-and-assert logic against TOML, YAML, and JSON representations of the same config. pkg/config/load_test.go:549TestFindFieldLineInContent maps field path strings to expected line numbers in a small table.

Mocking approach#

  • Strategy: Manual interface fakes — no code-generated mocks (no mockery, gomock, etc.).
  • Examples:
    • client/service_test.go:18-28failingConnector implements the Connector interface with a configurable error return; injected via ServiceOptions.ConnectorCreator to test that Run() cleans up on login failure.
    • server/group/base_test.go:15-48fakeLn implements net.Listener using buffered channels and a sync.Once close guard, used to drive the group’s worker goroutine without real TCP sockets.
    • test/e2e/mock/server/ — full in-process echo servers (streamserver.TCP/UDP/Unix, httpserver, oidcserver) implement the Server interface and serve as real network targets for E2E tests.

Integration tests#

  • Present: Yes — a sophisticated process-level E2E suite, plus some unit tests that bind real TCP ports.
  • How:
    • Process-based E2E (test/e2e/): the Framework writes rendered config files to temp directories, then starts actual frps and frpc binaries as child processes. Readiness is detected by either polling TCP connectivity (for frps) or watching the process’s stdout for "start proxy success" log lines per proxy (framework/process.go:125-145). After each test, processes are stopped, ports released, and temp directories cleaned.
    • In-process unit-level integration (client/service_test.go:34-44): getFreeTCPPort binds a real ephemeral TCP port, passes it as the admin server port, then asserts the port is released after a failed Run() — verifying real OS port lifecycle.
    • Ginkgo parallel E2E: test/e2e/e2e.go enables RandomizeAllSpecs = true and supports ParallelTotal/ParallelProcess sharding; the port allocator partitions port ranges by Ginkgo node to avoid collisions.
  • Separation: The entire E2E suite is under test/e2e/ and is invoked as a separate make e2e target (distinct from make test for unit tests). No build tags are used; separation is purely by directory and Makefile target.

E2E framework design (notable)#

The test/e2e/framework package is architecturally modeled after the Kubernetes E2E framework (Ginkgo BeforeEach/AfterEach lifecycle registration, CleanupActionHandle, SynchronizedBeforeSuite/SynchronizedAfterSuite). Key design points:

  • Template-based config generation: Configs are Go text templates with {{ .PortName }} placeholders. The Framework allocates real ports and renders the templates before writing them to disk, decoupling test logic from concrete port numbers.
  • Dual config lineage: Both test/e2e/legacy/ (INI-style, pre-v1 config) and test/e2e/v1/ (TOML/YAML-style) suites coexist, covering both config formats. Both are blank-imported into e2e_test.go to auto-register their Ginkgo specs.
  • NewRequestExpect fluent builder (framework/request.go): wraps the request package into a chainable assertion — .Protocol("tcp").PortName("Foo").ExpectError(false).Ensure() — used pervasively in E2E scenario specs.
  • Registered cleanup actions (framework/cleanup.go): a global list of cleanup handles ensures teardown runs even when Ginkgo aborts, preventing port leaks across test runs.

Test quality observations#

  • What’s done well:

    • The E2E framework is production-quality infrastructure: parallel-safe port allocation, process lifecycle management with readiness detection, and automatic cleanup. It validates real protocol behavior (TCP tunneling, QUIC, STCP, HTTP proxying) end-to-end.
    • Service-level unit tests use constructor injection (ServiceOptions.ConnectorCreator) precisely: they swap in a failingConnector to test cleanup paths without needing a real server. This is the correct use of the DI injection point from the patterns analysis.
    • Error sentinel testing is thorough — errors.Is(err, configmgmt.ErrConflict) etc. appear consistently in config manager tests, documenting and enforcing the error taxonomy.
    • Idempotency tests (TestCompleteProxyConfigurers_Idempotent, TestCompleteVisitorConfigurers_Idempotent) verify that calling Complete() twice on a config struct is safe — important for hot-reload correctness.
    • fakeLn in server/group/base_test.go is an exemplary minimal fake: channel-backed, sync.Once-closed, implements net.Listener cleanly. Tests cover fan-out, stop-on-close, and closed-channel panic recovery.
    • Config loading tests (pkg/config/load_test.go) are exhaustively parametric: TOML/YAML/JSON, strict vs. lenient, YAML merge keys, edge cases (array at root, scalar at root).
  • What could improve:

    • Unit test coverage ratio (~11%) is low relative to the codebase size. Core subsystems — proxy routing (client/proxy/), server connection handling (server/control.go), NAT hole-punching (pkg/nathole/) — have zero unit tests. All behavioral confidence in these paths comes from E2E, which is slower and harder to debug.
    • No -race flag is visible in the CI configuration (make alltestgo test without -race). For a codebase with 45 goroutines, 55 select blocks, 8 sync.Once, and atomic state, race-detector runs would be valuable.
    • E2E readiness still falls back to time.Sleep(1500ms) for visitor-only clients (framework/process.go:77-80), since visitors have no deterministic “ready” log line. This is a known limitation acknowledged in comments.
    • No benchmark (*_bench_test.go) or fuzz tests visible. The rate-limiting and bandwidth-shaping paths in pkg/util/limit/ would be natural candidates.
  • Patterns worth emulating:

    • Template-rendered config files for E2E: eliminates port-number constants, enables parallel test sharding, and keeps test specs focused on behavior rather than port management.
    • Process stdout WaitForOutput for readiness: instead of sleeping, scan the child process’s stdout for a specific log string. Used in waitForClientProxyReady — more deterministic and faster than fixed sleeps.
    • Constructor injection with a creator function (ConnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector): a single field in ServiceOptions makes the entire service testable without mocking frameworks, by substituting the transport layer.
    • fakeLn pattern: implementing net.Listener with channels and sync.Once is the idiomatic minimal fake for any code that Accept()s connections — reusable across many Go projects.