Unit Testing ChannelHandler.Read

Hello all,

I am writing a ChannelHandler that overrides the default functionality of _ChannelOutboundHandler.read(), so that it only reads inbound data from the socket when it's ready to handle it.

This is a snippet of the code:

public class SftpChannelHandler: ChannelDuplexHandler {
	public typealias InboundIn = MessagePart
	public typealias InboundOut = Never
	public typealias OutboundIn = Never
	public typealias OutboundOut = MessagePart

	private var state: State
	private var shouldRead: Bool = false

	public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
		// this will change the state and shouldRead in various ways, which affects the read(context:) below
	}

	public func read(context: ChannelHandlerContext) {
		switch state {
		case .awaitingHeader:
			// Always read when expecting the next packet header
			context.read()
		case .processingMessage(_):
			// Only read body data when the Combine bridged backpressure allows us.
			if shouldRead {
				context.read()
			}
		case .awaitingFinishedReply(_):
			// Never allow reads while the previous message is still being processed.
			return
		}
	}
}

I have a lot of unit tests written for this handler so far, using an EmbeddedChannel and its writeInbound() method to simulate data coming in from the socket for the handler to handle in channelRead(context:, data:).

However, through Xcode code coverage, my read(context:) is never called, so I have not been able to verify its logic yet through unit testing. This is advantageous because I am able to test for failure scenarios in channelRead(), but I would also like to verify that read() works correctly as well. This is important because this channel handler needs to stop reading from the socket while it's still processing the previous message to serialize the processing of incoming messages and their responses. This is similar to NIO's HTTPServerPipelineHandler.

Any thoughts or suggestions?

Side note: I've found that unit testing via EmbeddedChannel or unit testing other aspects of Swift NIO have been difficult to figure out. Anyone know of a good guide on this? Documentation is sparse or spread out.

And, this is an example unit test that triggers channelRead() but not read():

	func testValidNop() {
		let channel = EmbeddedChannel()
		var currentHandleMessagePromise: EventLoopPromise<Void>!
		var lastHandledMessage: SftpMessage?
		let customServer = CustomSftpServer(
			handleMessageHandler: { message in
				lastHandledMessage = message
				return currentHandleMessagePromise.futureResult
		})
		let sftpChannelHandler = SftpServerChannelHandler(server: customServer)

		XCTAssertNoThrow(try channel.pipeline.addHandler(sftpChannelHandler).wait())

		// Ensure NOPs work.
		let messagePart: MessagePart = .header(.nopDebug(NOPDebugPacket(message: "test")), 0)
		currentHandleMessagePromise = channel.eventLoop.makePromise()
		XCTAssertNoThrow(try channel.writeInbound(messagePart))
		currentHandleMessagePromise.completeWith(.success(()))
		XCTAssertEqual(lastHandledMessage?.packet, .some(.nopDebug(NOPDebugPacket(message: "test"))))

		XCTAssertNoThrow(try channel.throwIfErrorCaught())
		XCTAssertNoThrow(XCTAssert(try channel.finish().isClean))
	}

Hi James,

good question.

In "normal" non-test cases the Channel will generate the read events for you. (As long as you haven't modified the ChannelOptions.Types.AutoRead)
It will trigger once your channel has read (ChannelOptions.Types.MaxMessagesPerReadOption) or no more data is available on the socket.

The EmbeddedChannel does not create read events itself and thus doesn't trigger read at all. The best way to test your read() implementation is to call read on the EmbeddedChannel itself:

Given your SftpChannelHandler:

func testRead()
  let handler = SftpChannelHandler()
  let embedded = EmbeddedChannel(handler: handler)
        
  embedded.read() // triggers the read event
}

To check if the read event was passed on, you will need another handler in the pipeline to check whether the context.read() was invoked within your handler:

class ReadEventHitHandler: ChannelOutboundHandler {
  public typealias OutboundIn = NIOAny
  
  private(set) var readHitCounter = 0
  
  public init() {}
  
  public func read(context: ChannelHandlerContext) {
    self.readHitCounter += 1
    context.read()
  }
}

In the end your code should look something like this:

func testRead()
  let handler = SftpChannelHandler()
  let readEventHandler = ReadEventHitHandler
  let embedded = EmbeddedChannel(handlers: [readEventHandler, handler])

  XCTAssertEqual(readEventHandler.readHitCounter, 0)
  embedded.read() // triggers the read event
  XCTAssertEqual(readEventHandler.readHitCounter, 1) // expect event was passed through
  
  XCTAssertEqual(readEventHandler.readHitCounter, 1)
  embedded.read() // triggers the read event
  XCTAssertEqual(readEventHandler.readHitCounter, 1) // expect event was catched for some time
}

I hope this helps, if there are any questions unanswered, please reach out.

Thank you @fabianfett, this solves the problem exactly. I thought I had tried calling that method and didn't see my breakpoint get hit, but I may have forgotten an important step. Also, the second handler to count the calls to read(context:) is very helpful!

I am able to continue unit testing with this information, thanks!

1 Like

Update to this thread. As of swift-nio 2.32.1 (probably earlier as well), you can import NIOTestUtils and use the EventCounterHandler object to track the count of reads for you, as well as all of the other possible calls in a handler.

Although, the example above helps outline how it works nice and succinctly as well!