iosswiftxctestswift-nio

Is is possible to open SwiftNIO based server socket within XCTest test app?


I have an XCTest which works with UI components. I tried to open a server socket within the xctext function using SwiftNIO.

I took the echo server example from here. and I simplified, removed the args with hardcoded values for the sake of a dirty test.

import XCTest
import NIOCore
import NIOPosix

private final class EchoHandler: ChannelInboundHandler {
    public typealias InboundIn = ByteBuffer
    public typealias OutboundOut = ByteBuffer

    public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        // As we are not really interested getting notified on success or failure we just pass nil as promise to
        // reduce allocations.
        context.write(data, promise: nil)
    }

    // Flush it out. This can make use of gathering writes if multiple buffers are pending
    public func channelReadComplete(context: ChannelHandlerContext) {
        context.flush()
    }

    public func errorCaught(context: ChannelHandlerContext, error: Error) {
        print("error: ", error)

        // As we are not really interested getting notified on success or failure we just pass nil as promise to
        // reduce allocations.
        context.close(promise: nil)
    }
}

class MyXCTests: XCTestCase {
   var app: XCUIApplication!
   
   override func setUpWithError() throws {
       continueAfterFailure = false
       app = XCUIApplication()
       // Catch system alerts such as "allow connecting to Wi-fi network"
       addUIInterruptionMonitor(withDescription: "System Dialog") { (alert) -> Bool in
           alert.buttons["Join"].tap()
           return true
       }
   }

   override func tearDownWithError() throws {
   }

   func testXYZ() throws {
       app.launch()
        
       let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
       let bootstrap = ServerBootstrap(group: group)
           // Specify backlog and enable SO_REUSEADDR for the server itself
           .serverChannelOption(ChannelOptions.backlog, value: 256)
           .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)

           // Set the handlers that are appled to the accepted Channels
           .childChannelInitializer { channel in
               // Ensure we don't read faster than we can write by adding the BackPressureHandler into the pipeline.
               channel.pipeline.addHandler(BackPressureHandler()).flatMap { v in
                   channel.pipeline.addHandler(EchoHandler())
               }
           }

           // Enable SO_REUSEADDR for the accepted Channels
           .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
           .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16)
           .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator())
       defer {
           try! group.syncShutdownGracefully()
       }
        
       let channel = try { () -> Channel in
           return try bootstrap.bind(host: "0.0.0.0", port: 1234).wait()
       }()
        
       print("============= Server started and listening on \(channel.localAddress!)")

      // then some XCTest code which works here was cut from this snippet
}

The test runs correctly, it also prints

============= Server started and listening on [IPv4]0.0.0.0/0.0.0.0:1234

But in reality EchoClient from here doesn't work

swift run NIOEchoClient localhost 1234                                                                                   1785
[0/0] Build complete!
Please enter line to send to the server
dfsdfd
Swift/ErrorType.swift:200: Fatal error: Error raised at top level: NIOPosix.NIOConnectionError(host: "localhost", port: 1234, dnsAError: nil, dnsAAAAError: nil, connectionErrors: [NIOPosix.SingleConnectionFailure(target: [IPv6]localhost/::1:1234, error: connection reset (error set): Connection refused (errno: 61)), NIOPosix.SingleConnectionFailure(target: [IPv4]localhost/127.0.0.1:1234, error: connection reset (error set): Connection refused (errno: 61))])
[1]    28213 trace trap  swift run NIOEchoClient localhost 1234

The listening socket also unavailable with

sudo lsof -PiTCP -sTCP:LISTEN

I was also trying UITestEntitlements to set com.apple.security.app-sandbox to false.

Is there a way to allow server sockets from XCTest?

Originally I was trying to embed a Swift-GRPC endpoint, to allow more finer grained control from a HW in the loop controller. The intent is to start an XCTest using command line xcodebuild, which in turn is starting a long running test, but instead of the test code written in Swift, I would expose the events when to tap some buttons, right outside of the test process through a grpc endpoint.

Since the grpc endpoint didn't worked, I reduced the problem to the one above.

Anybody have a hint, how to pass through this issue, or have a hint why it will never be possible to open server socket within an XCTest app, don't hesitate to reply here.


Solution

  • Yes, that is possible, you can find many examples of this in the AsyncHTTPClient and SwiftNIO test suites.

    The reason that yours doesn't work is because you shut down the MultiThreadedEventLoopGroup right after binding the socket. So essentially you're starting everything up and then you shut it down again.

    Also, for unit tests, I'd recommend binding to 127.0.0.1 only because you probably don't want connections from elsewhere. Another good idea is to use an ephemeral port, ie. have the system pick a free, random port automatically. You can achieve this by specifying port 0. After you bind the server Channel you can then interrogate the server channel by using serverChannel.localAddress?.port! about the port it picked.

    Here's a full example with a client and a server in a test case.

    import XCTest
    import NIO
    
    final class ExampleTests: XCTestCase {
        // We keep the `EventLoopGroup` where all the I/O runs alive during the test
        private var group: EventLoopGroup!
        
        // Same for the server channel.
        private var serverChannel: Channel!
        private var serverAddress: SocketAddress {
            return self.serverChannel.localAddress!
        }
        
        // We set up the server in `setUp`...
        override func setUp() {
            self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
            XCTAssertNoThrow(self.serverChannel = try ServerBootstrap(group: self.group)
                .childChannelInitializer { channel in
                    channel.pipeline.addHandler(UppercasingHandler())
                }
                .bind(host: "127.0.0.1", port: 0) // port 0 means: pick a free port.
                .wait())
        }
        
        // ... and tear it down in `tearDown`.
        override func tearDown() {
            XCTAssertNoThrow(try self.serverChannel?.close().wait())
            XCTAssertNoThrow(try self.group?.syncShutdownGracefully())
        }
        
        func testExample() throws {
            // Here we just bootstrap a little client that sends "Hello world!\n"
            let clientChannel = try ClientBootstrap(group: self.group)
                .channelInitializer { channel in
                    channel.pipeline.addHandler(PrintEverythingHandler())
                }
                .connect(to: self.serverAddress)
                .wait()
            
            XCTAssertNoThrow(try clientChannel
                                .writeAndFlush(ByteBuffer(string: "Hello world!\n"))
                                .wait())
            XCTAssertNoThrow(try clientChannel.closeFuture.wait())
        }
    }
    
    // Just a handler that uses the C `toupper` function which uppercases characters.
    final class UppercasingHandler: ChannelInboundHandler {
        typealias InboundIn = ByteBuffer
        typealias OutboundOut = ByteBuffer
        
        func channelRead(context: ChannelHandlerContext, data: NIOAny) {
            let inBuffer = self.unwrapInboundIn(data)
            var outBuffer = context.channel.allocator.buffer(capacity: inBuffer.readableBytes)
            
            // Here we just upper case each byte using the C stdlib's `toupper` function.
            outBuffer.writeBytes(inBuffer.readableBytesView.map { UInt8(toupper(CInt($0))) })
            
            context.writeAndFlush(self.wrapOutboundOut(outBuffer),
                                  promise: nil)
        }
        
        // We want to close the connection on any error really.
        func errorCaught(context: ChannelHandlerContext, error: Error) {
            print("server: unexpected error \(error), closing")
            context.close(promise: nil)
        }
    }
    
    // This handler just prints everything using the `write` system call. And closes the connection on a newline.
    final class PrintEverythingHandler: ChannelInboundHandler {
        typealias InboundIn = ByteBuffer
        
        func channelRead(context: ChannelHandlerContext, data: NIOAny) {
            let inBuffer = self.unwrapInboundIn(data)
            
            guard inBuffer.readableBytes > 0 else {
                return
            }
            
            // We're using Unsafe* stuff here because we're using the `write` system call, which is a C function.
            _ = inBuffer.withUnsafeReadableBytes { ptr in
                write(STDOUT_FILENO, ptr.baseAddress!, ptr.count)
            }
            
            // If we see a newline, then let's actually close the connection...
            if inBuffer.readableBytesView.contains(UInt8(ascii: "\n")) {
                print("found newline, closing...")
                context.close(promise: nil)
            }
        }
        
        func errorCaught(context: ChannelHandlerContext, error: Error) {
            print("client: unexpected error \(error), closing")
            context.close(promise: nil)
        }
    }