Create NIOTestUtils & add B2MD verifier (#939)

Motivation:

When writing B2MDs, there are a couple of scenarios that always need to
be tested: firehose feeding, drip feeding, many messages, ...

It's tedious writing those tests over and over again for every B2MD.

Modifications:

- Add a simple B2MD verifier that users can use in unit tests.
- Add a new, public `NIOTestUtils` module which contains utilities
  mostly useful for testing. Crucially however, it does not depend on
  `XCTest` so it can be used it all targets.

Result:

Hopefully fewer bugs in B2MDs.
This commit is contained in:
Johannes Weiss 2019-05-10 17:29:13 +01:00 committed by GitHub
parent d1c7cd0bac
commit 5513bb202a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 469 additions and 1 deletions

View File

@ -59,16 +59,20 @@ var targets: [PackageDescription.Target] = [
dependencies: ["NIO"]),
.target(name: "NIOUDPEchoClient",
dependencies: ["NIO"]),
.target(name: "NIOTestUtils",
dependencies: ["NIO"]),
.testTarget(name: "NIOTests",
dependencies: ["NIO", "NIOFoundationCompat"]),
.testTarget(name: "NIOConcurrencyHelpersTests",
dependencies: ["NIOConcurrencyHelpers"]),
.testTarget(name: "NIOHTTP1Tests",
dependencies: ["NIOHTTP1", "NIOFoundationCompat"]),
dependencies: ["NIOHTTP1", "NIOFoundationCompat", "NIOTestUtils"]),
.testTarget(name: "NIOTLSTests",
dependencies: ["NIO", "NIOTLS", "NIOFoundationCompat"]),
.testTarget(name: "NIOWebSocketTests",
dependencies: ["NIO", "NIOWebSocket"]),
.testTarget(name: "NIOTestUtilsTests",
dependencies: ["NIOTestUtils"]),
]
let package = Package(
@ -94,6 +98,7 @@ let package = Package(
.library(name: "NIOConcurrencyHelpers", targets: ["NIOConcurrencyHelpers"]),
.library(name: "NIOFoundationCompat", targets: ["NIOFoundationCompat"]),
.library(name: "NIOWebSocket", targets: ["NIOWebSocket"]),
.library(name: "NIOTestUtils", targets: ["NIOTestUtils"]),
],
dependencies: [
],

View File

@ -0,0 +1,211 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2019 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import NIO
public enum ByteToMessageDecoderVerifier {
/// - seealso: verifyDecoder(inputOutputPairs:decoderFactory:)
///
/// Verify `ByteToMessageDecoder`s with `String` inputs
public static func verifyDecoder<Decoder: ByteToMessageDecoder>(stringInputOutputPairs: [(String, [Decoder.InboundOut])],
decoderFactory: @escaping () -> Decoder) throws where Decoder.InboundOut: Equatable {
let alloc = ByteBufferAllocator()
let ioPairs = stringInputOutputPairs.map { (ioPair: (String, [Decoder.InboundOut])) -> (ByteBuffer, [Decoder.InboundOut]) in
var buffer = alloc.buffer(capacity: ioPair.0.utf8.count)
buffer.writeString(ioPair.0)
return (buffer, ioPair.1)
}
return try ByteToMessageDecoderVerifier.verifyDecoder(inputOutputPairs: ioPairs, decoderFactory: decoderFactory)
}
/// Verifies a `ByteToMessageDecoder` by performing a number of tests.
///
/// This method is mostly useful in unit tests for `ByteToMessageDecoder`s. It feeds the inputs from
/// `inputOutputPairs` into the decoder in various ways and expects the decoder to produce the outputs from
/// `inputOutputPairs`.
///
/// The verification performs various tests, for example:
///
/// - drip feeding the bytes, one by one
/// - sending many messages in one `ByteBuffer`
/// - sending each complete message in one `ByteBuffer`
///
/// For `ExampleDecoder` that produces `ExampleDecoderOutput`s you would use this method the following way:
///
/// var exampleInput1 = channel.allocator.buffer(capacity: 16)
/// exampleInput1.writeString("example-in1")
/// var exampleInput2 = channel.allocator.buffer(capacity: 16)
/// exampleInput2.writeString("example-in2")
/// let expectedInOuts = [(exampleInput1, ExampleDecoderOutput("1")),
/// (exampleInput2, ExampleDecoderOutput("2"))
/// ]
/// XCTAssertNoThrow(try ByteToMessageDecoderVerifier.verifyDecoder(inputOutputPairs: expectedInOuts,
/// decoderFactory: { ExampleDecoder() }))
public static func verifyDecoder<Decoder: ByteToMessageDecoder>(inputOutputPairs: [(ByteBuffer, [Decoder.InboundOut])],
decoderFactory: @escaping () -> Decoder) throws where Decoder.InboundOut: Equatable {
typealias Out = Decoder.InboundOut
func verifySimple(channel: RecordingChannel) throws {
for (input, expectedOutputs) in inputOutputPairs.shuffled() {
try channel.writeInbound(input)
for expectedOutput in expectedOutputs {
guard let actualOutput = try channel.readInbound(as: Out.self) else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .underProduction(expectedOutput))
}
guard actualOutput == expectedOutput else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .wrongProduction(actual: actualOutput,
expected: expectedOutput))
}
}
let actualExtraOutput = try channel.readInbound(as: Out.self)
guard actualExtraOutput == nil else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .overProduction(actualExtraOutput!))
}
}
}
func verifyDripFeed(channel: RecordingChannel) throws {
for _ in 0..<10 {
for (input, expectedOutputs) in inputOutputPairs.shuffled() {
for c in input.readableBytesView {
var buffer = channel.allocator.buffer(capacity: 12)
buffer.writeString("BEFORE")
buffer.writeInteger(c)
buffer.writeString("AFTER")
buffer.moveReaderIndex(forwardBy: 6)
buffer.moveWriterIndex(to: buffer.readerIndex + 1)
try channel.writeInbound(buffer)
}
for expectedOutput in expectedOutputs {
guard let actualOutput = try channel.readInbound(as: Out.self) else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .underProduction(expectedOutput))
}
guard actualOutput == expectedOutput else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .wrongProduction(actual: actualOutput,
expected: expectedOutput))
}
}
let actualExtraOutput = try channel.readInbound(as: Out.self)
guard actualExtraOutput == nil else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .overProduction(actualExtraOutput!))
}
}
}
}
func verifyManyAtOnce(channel: RecordingChannel) throws {
var overallBuffer = channel.allocator.buffer(capacity: 1024)
var overallExpecteds: [Out] = []
for _ in 0..<10 {
for (var input, expectedOutputs) in inputOutputPairs.shuffled() {
overallBuffer.writeBuffer(&input)
overallExpecteds.append(contentsOf: expectedOutputs)
}
}
try channel.writeInbound(overallBuffer)
for expectedOutput in overallExpecteds {
guard let actualOutput = try channel.readInbound(as: Out.self) else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .underProduction(expectedOutput))
}
guard actualOutput == expectedOutput else {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .wrongProduction(actual: actualOutput,
expected: expectedOutput))
}
}
}
let decoder: Decoder = decoderFactory()
let channel = RecordingChannel(EmbeddedChannel(handler: ByteToMessageHandler<Decoder>(decoder)))
try verifySimple(channel: channel)
try verifyDripFeed(channel: channel)
try verifyManyAtOnce(channel: channel)
if case .leftOvers(inbound: let ib, outbound: let ob, pendingOutbound: let pob) = try channel.finish() {
throw VerificationError<Out>(inputs: channel.inboundWrites,
errorCode: .leftOversOnDeconstructingChannel(inbound: ib,
outbound: ob,
pendingOutbound: pob))
}
}
}
extension ByteToMessageDecoderVerifier {
private class RecordingChannel {
private let actualChannel: EmbeddedChannel
private(set) var inboundWrites: [ByteBuffer] = []
init(_ actualChannel: EmbeddedChannel) {
self.actualChannel = actualChannel
}
func readInbound<T>(as type: T.Type = T.self) throws -> T? {
return try self.actualChannel.readInbound()
}
@discardableResult public func writeInbound(_ data: ByteBuffer) throws -> EmbeddedChannel.BufferState {
self.inboundWrites.append(data)
return try self.actualChannel.writeInbound(data)
}
var allocator: ByteBufferAllocator {
return self.actualChannel.allocator
}
func finish() throws -> EmbeddedChannel.LeftOverState {
return try self.actualChannel.finish()
}
}
}
extension ByteToMessageDecoderVerifier {
/// A `VerificationError` is thrown when the verification of a `ByteToMessageDecoder` failed.
public struct VerificationError<OutputType: Equatable>: Error {
/// Contains the `inputs` that were passed to the `ByteToMessageDecoder` at the point where it failed
/// verification.
public var inputs: [ByteBuffer]
/// `errorCode` describes the concrete problem that was detected.
public var errorCode: ErrorCode
public enum ErrorCode {
/// The `errorCode` will be `wrongProduction` when the `expected` output didn't match the `actual`
/// output.
case wrongProduction(actual: OutputType, expected: OutputType)
/// The `errorCode` will be set to `overProduction` when a decoding result was yielded where
/// nothing was expected.
case overProduction(OutputType)
/// The `errorCode` will be set to `underProduction` when a decoder didn't yield output when output was
/// expected. The expected output is delivered as the associated value.
case underProduction(OutputType)
/// The `errorCode` will be set to `leftOversOnDeconstructionChannel` if there were left-over items
/// in the `Channel` on deconstruction. This usually means that your `ByteToMessageDecoder` did not process
/// certain items.
case leftOversOnDeconstructingChannel(inbound: [NIOAny], outbound: [NIOAny], pendingOutbound: [NIOAny])
}
}
}

View File

@ -26,6 +26,7 @@ import XCTest
@testable import NIOConcurrencyHelpersTests
@testable import NIOHTTP1Tests
@testable import NIOTLSTests
@testable import NIOTestUtilsTests
@testable import NIOTests
@testable import NIOWebSocketTests
@ -40,6 +41,7 @@ import XCTest
testCase(ByteBufferTest.allTests),
testCase(ByteBufferUtilsTest.allTests),
testCase(ByteToMessageDecoderTest.allTests),
testCase(ByteToMessageDecoderVerifierTest.allTests),
testCase(ChannelNotificationTest.allTests),
testCase(ChannelOptionStorageTest.allTests),
testCase(ChannelPipelineTest.allTests),

View File

@ -45,6 +45,7 @@ extension HTTPDecoderTest {
("testNonASCIIWorksAsHeaderValue", testNonASCIIWorksAsHeaderValue),
("testDoesNotDeliverLeftoversUnnecessarily", testDoesNotDeliverLeftoversUnnecessarily),
("testHTTPResponseWithoutHeaders", testHTTPResponseWithoutHeaders),
("testBasicVerifications", testBasicVerifications),
]
}
}

View File

@ -15,6 +15,7 @@
import XCTest
import NIO
import NIOHTTP1
import NIOTestUtils
class HTTPDecoderTest: XCTestCase {
private var channel: EmbeddedChannel!
@ -547,4 +548,49 @@ class HTTPDecoderTest: XCTestCase {
XCTAssertNoThrow(XCTAssertEqual(HTTPClientResponsePart.head(.init(version: .init(major: 1, minor: 0),
status: .ok)), try channel.readInbound()))
}
func testBasicVerifications() {
let byteBufferContainingJustAnX: ByteBuffer = {
var buffer = ByteBufferAllocator().buffer(capacity: 1)
buffer.writeString("X")
return buffer
}()
let expectedInOuts: [(String, [HTTPServerRequestPart])] = [
("GET / HTTP/1.1\r\n\r\n",
[.head(.init(version: .init(major: 1, minor: 1), method: .GET, uri: "/")),
.end(nil)]),
("POST /foo HTTP/1.1\r\n\r\n",
[.head(.init(version: .init(major: 1, minor: 1), method: .POST, uri: "/foo")),
.end(nil)]),
("POST / HTTP/1.1\r\ncontent-length: 1\r\n\r\nX",
[.head(.init(version: .init(major: 1, minor: 1),
method: .POST,
uri: "/",
headers: .init([("content-length", "1")]))),
.body(byteBufferContainingJustAnX),
.end(nil)]),
("POST / HTTP/1.1\r\ntransfer-encoding: chunked\r\n\r\n1\r\nX\r\n0\r\n\r\n",
[.head(.init(version: .init(major: 1, minor: 1),
method: .POST,
uri: "/",
headers: .init([("transfer-encoding", "chunked")]))),
.body(byteBufferContainingJustAnX),
.end(nil)]),
("POST / HTTP/1.1\r\ntransfer-encoding: chunked\r\none: two\r\n\r\n1\r\nX\r\n0\r\nfoo: bar\r\n\r\n",
[.head(.init(version: .init(major: 1, minor: 1),
method: .POST,
uri: "/",
headers: .init([("transfer-encoding", "chunked"), ("one", "two")]))),
.body(byteBufferContainingJustAnX),
.end(.init([("foo", "bar")]))]),
]
let expectedInOutsBB: [(ByteBuffer, [HTTPServerRequestPart])] = expectedInOuts.map { io in
var buffer = ByteBufferAllocator().buffer(capacity: io.0.utf8.count)
buffer.writeString(io.0)
return (buffer, io.1)
}
XCTAssertNoThrow(try ByteToMessageDecoderVerifier.verifyDecoder(inputOutputPairs: expectedInOutsBB,
decoderFactory: { HTTPRequestDecoder() }))
}
}

View File

@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
//
// ByteToMessageDecoderVerifierTest+XCTest.swift
//
import XCTest
///
/// NOTE: This file was generated by generate_linux_tests.rb
///
/// Do NOT edit this file directly as it will be regenerated automatically when needed.
///
extension ByteToMessageDecoderVerifierTest {
static var allTests : [(String, (ByteToMessageDecoderVerifierTest) -> () throws -> Void)] {
return [
("testWrongResults", testWrongResults),
("testNoOutputWhenWeShouldHaveOutput", testNoOutputWhenWeShouldHaveOutput),
("testOutputWhenWeShouldNotProduceOutput", testOutputWhenWeShouldNotProduceOutput),
("testLeftovers", testLeftovers),
]
}
}

View File

@ -0,0 +1,167 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2019 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
@testable import NIO
import NIOTestUtils
import XCTest
typealias VerificationError = ByteToMessageDecoderVerifier.VerificationError<String>
class ByteToMessageDecoderVerifierTest: XCTestCase {
func testWrongResults() {
struct AlwaysProduceY: ByteToMessageDecoder {
typealias InboundOut = String
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
buffer.moveReaderIndex(to: buffer.writerIndex)
context.fireChannelRead(self.wrapInboundOut("Y"))
return .needMoreData
}
func decodeLast(context: ChannelHandlerContext,
buffer: inout ByteBuffer,
seenEOF: Bool) throws -> DecodingState {
while try self.decode(context: context, buffer: &buffer) == .continue {}
return .needMoreData
}
}
XCTAssertThrowsError(try ByteToMessageDecoderVerifier.verifyDecoder(stringInputOutputPairs: [("x", ["x"])],
decoderFactory: AlwaysProduceY.init)) {
error in
switch error {
case let error as VerificationError:
XCTAssertEqual(1, error.inputs.count)
switch error.errorCode {
case .wrongProduction(actual: let actual, expected: let expected):
XCTAssertEqual("Y", actual)
XCTAssertEqual("x", expected)
default:
XCTFail("unexpected error: \(error)")
}
default:
XCTFail("unexpected error: \(error)")
}
}
}
func testNoOutputWhenWeShouldHaveOutput() {
struct NeverProduce: ByteToMessageDecoder {
typealias InboundOut = String
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
buffer.moveReaderIndex(to: buffer.writerIndex)
return .needMoreData
}
func decodeLast(context: ChannelHandlerContext,
buffer: inout ByteBuffer,
seenEOF: Bool) throws -> DecodingState {
while try self.decode(context: context, buffer: &buffer) == .continue {}
return .needMoreData
}
}
XCTAssertThrowsError(try ByteToMessageDecoderVerifier.verifyDecoder(stringInputOutputPairs: [("x", ["x"])],
decoderFactory: NeverProduce.init)) {
error in
switch error {
case let error as VerificationError:
XCTAssertEqual(1, error.inputs.count)
switch error.errorCode {
case .underProduction(let expected):
XCTAssertEqual("x", expected)
default:
XCTFail("unexpected error: \(error)")
}
default:
XCTFail("unexpected error: \(error)")
}
}
}
func testOutputWhenWeShouldNotProduceOutput() {
struct ProduceTooEarly: ByteToMessageDecoder {
typealias InboundOut = String
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
context.fireChannelRead(self.wrapInboundOut("Y"))
return .needMoreData
}
func decodeLast(context: ChannelHandlerContext,
buffer: inout ByteBuffer,
seenEOF: Bool) throws -> DecodingState {
while try self.decode(context: context, buffer: &buffer) == .continue {}
return .needMoreData
}
}
XCTAssertThrowsError(try ByteToMessageDecoderVerifier.verifyDecoder(stringInputOutputPairs: [("xxxxxx", ["Y"])],
decoderFactory: ProduceTooEarly.init)) {
error in
switch error {
case let error as VerificationError:
switch error.errorCode {
case .overProduction(let actual):
XCTAssertEqual("Y", actual)
default:
XCTFail("unexpected error: \(error)")
}
default:
XCTFail("unexpected error: \(error)")
}
}
}
func testLeftovers() {
struct NeverDoAnything: ByteToMessageDecoder {
typealias InboundOut = String
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
return .needMoreData
}
func decodeLast(context: ChannelHandlerContext,
buffer: inout ByteBuffer,
seenEOF: Bool) throws -> DecodingState {
while try self.decode(context: context, buffer: &buffer) == .continue {}
if buffer.readableBytes > 0 {
context.fireChannelRead(self.wrapInboundOut("leftover"))
}
return .needMoreData
}
}
XCTAssertThrowsError(try ByteToMessageDecoderVerifier.verifyDecoder(stringInputOutputPairs: [("xxxxxx", [])],
decoderFactory: NeverDoAnything.init)) {
error in
switch error {
case let error as VerificationError:
switch error.errorCode {
case .leftOversOnDeconstructingChannel(inbound: let inbound,
outbound: let outbound,
pendingOutbound: let pending):
XCTAssertEqual(0, outbound.count)
XCTAssertEqual(["leftover"], inbound.map { $0.tryAs(type: String.self) })
XCTAssertEqual(0, pending.count)
default:
XCTFail("unexpected error: \(error)")
}
default:
XCTFail("unexpected error: \(error)")
}
}
}
}