Enhanced test fixtures for integration tests. (#1834)

- Part of #1833.
- Adds `ContainerFixture` with scoped resource lifecycle and cleanup in
place of implementation inheritance for test support functions. The
fixture also handles resource prefixing and uses a more ergonomic
`CommandResult` in place of a tuple for return values.
- `ImageWarmup` suite pre-pulls well-known images, and
`copyWarmupImage()` tags test-local refs, keeping the canonical image
store untouched.
- Three-phase `integration-new`: warmup, followed by concurrent tests
(managed by the swift test
`--experimental-maximum-parallelization-width` flag), followed by
serialized tests.
- `coverage-new` merges unit + integration-new profraw, replacing
`coverage` in CI as a migration progress indicator.
- Updates GH workflow so non-coverage invokes both the `integration` and
`integration-new` Makefile targets, while coverage runs invoke the
`coverage-new` target.
This commit is contained in:
J Logan
2026-06-26 12:00:44 -07:00
committed by GitHub
parent aa7fef3d1d
commit 649164d9e2
8 changed files with 537 additions and 4 deletions
+2 -2
View File
@@ -103,11 +103,11 @@ jobs:
- name: Test the container project
if: ${{ !inputs.coverage }}
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration integration-new
- name: Test the container project with coverage
if: ${{ inputs.coverage }}
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" install-kernel coverage
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" install-kernel coverage-new
- name: Extract coverage percentages
if: ${{ inputs.coverage }}
+67 -2
View File
@@ -151,7 +151,7 @@ dsym:
.PHONY: test
test:
@$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI
@$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
.PHONY: install-kernel
install-kernel:
@@ -194,6 +194,56 @@ define GENERATE_COV_REPORTS
@cat $(COVERAGE_OUTPUT_DIR)/$(2)/coverage-percent.txt
endef
# New integration test infrastructure.
# PARALLEL_WIDTH controls --experimental-maximum-parallelization-width for the
# concurrent pass. WARMUP_FILTER, CONCURRENT_FILTER, and GLOBAL_FILTER select
# the three phases. Expand the filter lists as suites are migrated from CLITests.
PARALLEL_WIDTH ?= 2
WARMUP_FILTER = ImageWarmup
CONCURRENT_FILTER = DemoConcurrentTests
GLOBAL_FILTER = DemoGlobalTests
INTEGRATION_SWIFT_EXTRA ?=
INTEGRATION_POST_TEST ?=
define RUN_INTEGRATION
@echo Ensuring apiserver stopped before the CLI integration tests...
@bin/container system stop && sleep 3 && scripts/ensure-container-stopped.sh
@if [ -n "$(APP_ROOT)" ]; then \
echo "Clearing application data under $(APP_ROOT) (preserving kernels)..." ; \
mkdir -p $(APP_ROOT) ; \
find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 ! -name kernels -exec rm -rf {} + ; \
fi
@echo Running the integration tests...
@bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \
{ \
CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \
CONTAINER_CLI_PATH=$(ROOT_DIR)/bin/container && export CONTAINER_CLI_PATH ; \
echo "==> Warmup pass" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(WARMUP_FILTER)" && \
echo "==> Concurrent pass (width=$(PARALLEL_WIDTH))" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width $(PARALLEL_WIDTH) --filter "$(CONCURRENT_FILTER)" && \
echo "==> Global pass (serial)" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(GLOBAL_FILTER)" ; \
exit_code=$$? ; \
$(INTEGRATION_POST_TEST) \
echo Ensuring apiserver stopped after the CLI integration tests ; \
scripts/ensure-container-stopped.sh ; \
exit $${exit_code} ; \
}
endef
.PHONY: integration-new
integration-new: init-block
$(RUN_INTEGRATION)
.PHONY: coverage-integration-new
coverage-integration-new: INTEGRATION_SWIFT_EXTRA = --skip-build --enable-code-coverage
coverage-integration-new: INTEGRATION_POST_TEST = cp $(COV_DATA_DIR)/*.profraw $(COVERAGE_OUTPUT_DIR)/integration/ ;
coverage-integration-new: all
@mkdir -p $(COVERAGE_OUTPUT_DIR)/integration
$(RUN_INTEGRATION)
INTEGRATION_TEST_SUITES ?= \
TestCLIHelp \
TestCLIStatus \
@@ -228,6 +278,21 @@ empty :=
space := $(empty) $(empty)
INTEGRATION_FILTER := $(subst $(space),|,$(strip $(INTEGRATION_TEST_SUITES)))
.PHONY: coverage-new
# Merges unit coverage with integration-new coverage. Use this during migration;
# replace coverage with coverage-new in CI until all legacy tests are removed.
coverage-new: coverage-build coverage-unit coverage-integration-new
@echo Merging integration coverage profdata...
@xcrun llvm-profdata merge -sparse $(COVERAGE_OUTPUT_DIR)/integration/*.profraw -o $(COVERAGE_OUTPUT_DIR)/integration/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/integration/default.profdata,integration)
@echo Merging combined coverage profdata...
@mkdir -p $(COVERAGE_OUTPUT_DIR)/combined
@xcrun llvm-profdata merge -sparse \
$(COVERAGE_OUTPUT_DIR)/unit/default.profdata \
$(COVERAGE_OUTPUT_DIR)/integration/default.profdata \
-o $(COVERAGE_OUTPUT_DIR)/combined/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/combined/default.profdata,combined)
.PHONY: coverage-build
coverage-build:
@echo Building tests with coverage instrumentation...
@@ -249,7 +314,7 @@ coverage-unit:
@echo Running unit test coverage...
@rm -f $(COV_DATA_DIR)/*.profraw
@mkdir -p $(COVERAGE_OUTPUT_DIR)/unit
@$(SWIFT) test --skip-build --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI
@$(SWIFT) test --skip-build --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
@echo Merging unit coverage profdata...
@xcrun llvm-profdata merge -sparse $(COV_DATA_DIR)/*.profraw -o $(COVERAGE_OUTPUT_DIR)/unit/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/unit/default.profdata,unit)
+10
View File
@@ -80,6 +80,16 @@ let package = Package(
],
path: "Sources/CLI"
),
.testTarget(
name: "IntegrationTests",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerLog",
"Yams",
],
path: "Tests/IntegrationTests"
),
.testTarget(
name: "CLITests",
dependencies: [
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Testing
/// Demonstration suite for the concurrent test pass.
///
/// These eight tests run under ``--experimental-maximum-parallelization-width``
/// to show bounded parallelism. Each test starts an isolated container (name
/// scoped to its ``ContainerFixture/testID``) and sleeps for a random interval,
/// so the total wall-clock time should be roughly max(individual durations)
/// rather than their sum.
///
/// Delete this suite once real tests have been migrated to ``IntegrationTests``.
@Suite
struct DemoConcurrentTests {
@Test func test1() async throws { try await runDemo() }
@Test func test2() async throws { try await runDemo() }
@Test func test3() async throws { try await runDemo() }
@Test func test4() async throws { try await runDemo() }
@Test func test5() async throws { try await runDemo() }
@Test func test6() async throws { try await runDemo() }
@Test func test7() async throws { try await runDemo() }
@Test func test8() async throws { try await runDemo() }
private func runDemo() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
try await f.withContainer(image: image) { _ in
try await Task.sleep(for: .seconds(Int.random(in: 2...4)))
}
}
}
}
@@ -0,0 +1,41 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Testing
/// Demonstration suite for the serial global test pass.
///
/// These two tests are structurally identical to ``DemoConcurrentTests`` but
/// run under ``--experimental-maximum-parallelization-width 1`` in the Makefile
/// to show serial execution. Total wall-clock time should be approximately the
/// sum of the individual durations rather than the maximum.
///
/// Real global tests (image prune, system df, kernel set, etc.) will live here
/// once migrated. Delete this suite at that point.
@Suite
struct DemoGlobalTests {
@Test func globalTest1() async throws { try await runDemo() }
@Test func globalTest2() async throws { try await runDemo() }
private func runDemo() async throws {
try await ContainerFixture.with { f in
let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0])
try await f.withContainer(image: image) { _ in
try await Task.sleep(for: .seconds(Int.random(in: 2...4)))
}
}
}
}
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
struct CommandResult: Sendable {
let outputData: Data
let errorData: Data
let status: Int32
var output: String {
String(data: outputData, encoding: .utf8) ?? ""
}
var error: String {
String(data: errorData, encoding: .utf8) ?? ""
}
@discardableResult
func check(_ message: String? = nil) throws -> CommandResult {
guard status == 0 else {
let detail = message ?? error.trimmingCharacters(in: .whitespacesAndNewlines)
throw CommandError.nonZeroExit(status, detail)
}
return self
}
}
enum CommandError: Error {
case binaryNotFound
case executionFailed(String)
case nonZeroExit(Int32, String)
}
@@ -0,0 +1,293 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerLog
import Foundation
import Logging
import Synchronization
import SystemPackage
import Testing
/// Per-test fixture providing CLI execution, resource lifecycle, and cleanup.
///
/// Each test gets an isolated instance via ``ContainerFixture/with(_:)``. All
/// resources (containers, networks, volumes, images, scratch files) created
/// through the fixture are tracked and torn down automatically when the scope
/// exits whether the test passes, fails, or throws.
///
/// Tier 1 unstructured: call ``addCleanup(_:)`` to register any async
/// closure. Closures run LIFO on scope exit.
///
/// Tier 2 structured: helpers like ``withContainer(image:tag:runArgs:containerArgs:_:)``
/// register cleanup on your behalf and express resource lifetime as a scope.
final class ContainerFixture: Sendable {
// MARK: - Well-known images
/// Images preloaded by the ImageWarmup suite before concurrent tests run.
/// Add new commonly-used images here; the warmup pass pulls them in parallel.
static let warmupImages: [String] = [
"ghcr.io/linuxcontainers/alpine:3.20",
"ghcr.io/linuxcontainers/alpine:3.18",
"ghcr.io/containerd/busybox:1.36",
]
// MARK: - Per-instance state
/// Short random identifier prefixed to every resource this test creates.
let testID: String
/// Scratch directory for build inputs, test data, and command output.
/// Created at fixture init; removed on cleanup unless ``CLITEST_PRESERVE_SCRATCH``
/// is set in the environment.
let testDir: FilePath
private let log: Logger
private let cleanupTasks: Mutex<[@Sendable () async throws -> Void]> = .init([])
private static let commandSeq: Mutex<Int> = .init(0)
// MARK: - Lifecycle
private init(testID: String, testDir: FilePath, log: Logger) {
self.testID = testID
self.testDir = testDir
self.log = log
}
/// Runs `body` with a fresh fixture, then tears down all registered resources.
///
/// Cleanup runs in LIFO order regardless of whether `body` throws.
@discardableResult
static func with<T>(_ body: (ContainerFixture) async throws -> T) async throws -> T {
let testID = String(UUID().uuidString.prefix(8)).lowercased()
let scratchRoot =
ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"]
.map { FilePath($0) }
?? FilePath(FileManager.default.temporaryDirectory.path)
let testDir = scratchRoot.appending(testID)
try FileManager.default.createDirectory(
atPath: testDir.string, withIntermediateDirectories: true, attributes: nil)
let testName =
Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name }
?? testID
let suiteName = Test.current.map { "\(type(of: $0))" } ?? "unknown"
var logger = Logger(label: "com.apple.container.test") { label in
if let root = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !root.isEmpty {
let path =
FilePath(root)
.appending("clitests")
.appending(suiteName)
.appending(testName + ".log")
if let handler = try? FileLogHandler(label: label, category: "clitests", path: path) {
return handler
}
}
return StderrLogHandler()
}
logger[metadataKey: "testID"] = "\(testID)"
let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger)
if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] == nil {
fixture.addCleanup {
try? FileManager.default.removeItem(atPath: testDir.string)
}
}
do {
let result = try await body(fixture)
await fixture.runCleanup()
return result
} catch {
await fixture.runCleanup()
throw error
}
}
/// Registers a cleanup closure to run when the fixture scope exits.
/// Closures execute in LIFO order.
func addCleanup(_ task: @escaping @Sendable () async throws -> Void) {
cleanupTasks.withLock { $0.append(task) }
}
private func runCleanup() async {
let tasks = cleanupTasks.withLock { tasks -> [@Sendable () async throws -> Void] in
let reversed = Array(tasks.reversed())
tasks.removeAll()
return reversed
}
for task in tasks {
try? await task()
}
}
// MARK: - CLI execution
private var executableURL: URL {
get throws {
let path: FilePath
if let env = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] {
path = FilePath(env)
} else {
let candidate = FilePath(FileManager.default.currentDirectoryPath)
.appending("bin").appending("container")
guard FileManager.default.fileExists(atPath: candidate.string) else {
throw CommandError.binaryNotFound
}
path = candidate
}
return URL(filePath: path.string)
}
}
/// Runs the container CLI with the given arguments and returns the result.
///
/// Throws ``CommandError`` only for execution failures (binary not found,
/// process launch error). A non-zero exit status is represented in
/// ``CommandResult/status`` call ``CommandResult/check(_:)`` to turn it
/// into a thrown error.
func run(
_ arguments: [String],
stdin: Data? = nil,
currentDirectory: FilePath? = nil,
env: [String: String] = [:]
) throws -> CommandResult {
let seq = Self.commandSeq.withLock { n in
defer { n += 1 }
return n
}
log.info(
"command start",
metadata: ["seq": "\(seq)", "args": "\(arguments.joined(separator: " "))"])
let process = Process()
process.executableURL = try executableURL
process.arguments = arguments
if let dir = currentDirectory { process.currentDirectoryURL = URL(filePath: dir.string) }
if !env.isEmpty {
var e = ProcessInfo.processInfo.environment
for (k, v) in env { e[k] = v }
process.environment = e
}
let inputPipe = Pipe()
process.standardInput = inputPipe
// Write stdout/stderr to temp files to avoid blocking on full pipe buffers.
let tmpDir = FilePath(FileManager.default.temporaryDirectory.path)
.appending(UUID().uuidString)
try FileManager.default.createDirectory(
atPath: tmpDir.string, withIntermediateDirectories: true, attributes: nil)
defer { try? FileManager.default.removeItem(atPath: tmpDir.string) }
let stdoutPath = tmpDir.appending("stdout")
let stderrPath = tmpDir.appending("stderr")
FileManager.default.createFile(atPath: stdoutPath.string, contents: nil)
FileManager.default.createFile(atPath: stderrPath.string, contents: nil)
let stdoutHandle = try FileHandle(forWritingTo: URL(filePath: stdoutPath.string))
defer { try? stdoutHandle.close() }
let stderrHandle = try FileHandle(forWritingTo: URL(filePath: stderrPath.string))
defer { try? stderrHandle.close() }
process.standardOutput = stdoutHandle
process.standardError = stderrHandle
do {
try process.run()
} catch {
throw CommandError.executionFailed("process launch failed: \(error)")
}
if let data = stdin { inputPipe.fileHandleForWriting.write(data) }
inputPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let outputData = (try? Data(contentsOf: URL(filePath: stdoutPath.string))) ?? Data()
let errorData = (try? Data(contentsOf: URL(filePath: stderrPath.string))) ?? Data()
log.info(
"command end",
metadata: [
"seq": "\(seq)",
"status": "\(process.terminationStatus)",
])
return CommandResult(
outputData: outputData,
errorData: errorData,
status: process.terminationStatus)
}
// MARK: - Image helpers
/// Tags a warmup image to a test-local reference and registers its removal.
///
/// The returned name is `{testID}-{imageName}:{tag}`, e.g.
/// `a3f7c2b1-alpine:3.20`. Tests operate freely on this reference;
/// the canonical warmup image is never touched.
func copyWarmupImage(_ canonical: String) throws -> String {
let lastComponent = canonical.split(separator: "/").last.map(String.init) ?? canonical
let parts = lastComponent.split(separator: ":", maxSplits: 1)
let name = String(parts[0])
let tag = parts.count > 1 ? String(parts[1]) : "latest"
let localRef = "\(testID)-\(name):\(tag)"
try run(["image", "tag", canonical, localRef]).check()
addCleanup {
_ = try? self.run(["image", "rm", localRef])
}
return localRef
}
// MARK: - Container helpers
/// Runs a container, calls `body`, then stops and removes the container.
///
/// The container name is `{testID}-{tag}`. Supply a `tag` when a test
/// needs more than one container to avoid name collisions.
func withContainer(
image: String,
tag: String = "c",
runArgs: [String] = [],
containerArgs: [String] = ["sleep", "infinity"],
_ body: (String) async throws -> Void
) async throws {
let name = "\(testID)-\(tag)"
let args = ["run", "--rm", "--name", name, "-d"] + runArgs + [image] + containerArgs
try run(args).check()
defer {
_ = try? run(["stop", "-s", "SIGKILL", name])
}
try await body(name)
}
/// Polls until the named container reaches the `running` state.
func waitForContainerRunning(_ name: String, attempts: Int = 30) throws {
for _ in 0..<attempts {
if let result = try? run(["inspect", name]),
result.status == 0,
result.output.contains("\"running\"")
{
return
}
sleep(1)
}
throw CommandError.executionFailed("container '\(name)' did not reach running state")
}
}
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Testing
/// Pulls each image in ``ContainerFixture/warmupImages`` in parallel before
/// concurrent integration tests run. The Makefile's warmup pass runs this
/// suite first so that ``ContainerFixture/copyWarmupImage(_:)`` can tag
/// from a pre-populated store rather than pulling on demand.
@Suite
struct ImageWarmup {
@Test(arguments: ContainerFixture.warmupImages)
func pull(image: String) async throws {
try await ContainerFixture.with { f in
try f.run(["image", "pull", image]).check("failed to pull \(image)")
}
}
}