【问题标题】:How to unit-test Java socket server/client pair using Spock?如何使用 Spock 对 Java 套接字服务器/客户端对进行单元测试?
【发布时间】:2019-04-02 13:26:16
【问题描述】:

我正在尝试使用 Spock 为套接字客户端和服务器编写单元测试。我应该如何在单元测试中设置服务器/客户端对以使其正常工作?

通过在测试之外手动运行我的服务器类,我已经成功地测试了我的客户端类,但是当我尝试在测试类内部对其进行初始化时,所有测试似乎要么挂起,要么我得到一个拒绝连接。

目前我只是在使用 EchoServer and EchoClient code from Oracle 的略微修改版本。

客户端类:

public class EchoClient {
    private Socket echoSocket;
    private PrintWriter out;
    private BufferedReader in;


    public void startConnection(String hostName, int portNumber) throws IOException {
        try {
            echoSocket = new Socket(hostName, portNumber);
            out = new PrintWriter(echoSocket.getOutputStream(), true);
            in = new BufferedReader(new InputStreamReader(echoSocket.getInputStream()));
        } catch (UnknownHostException e) {
            System.err.printf("Don't know about host %s%n", hostName);
            System.exit(1);
        } catch (IOException e) {
            System.err.printf("Couldn't get I/O for the connection to %s%n", hostName);
            System.exit(1);
        }
    }

    public String sendMessage(String msg) throws IOException {
        out.println(msg);
        return in.readLine();
    }
}

服务器启动方式:

public void start(int portNumber) throws IOException {
    try (
            ServerSocket serverSocket =
                    new ServerSocket(portNumber);
            Socket clientSocket = serverSocket.accept();
            PrintWriter out =
                    new PrintWriter(clientSocket.getOutputStream(), true);
            BufferedReader in = new BufferedReader(
                    new InputStreamReader(clientSocket.getInputStream()));
    ) {
        String inputLine;
        while ((inputLine = in.readLine()) != null) {
            out.println(inputLine);
        }
    } catch (IOException e) {
        System.out.println("Exception caught when trying to listen on port "
                + portNumber + " or listening for a connection");
        System.out.println(e.getMessage());
    }
}

Spock 测试:

class EchoClientTest extends Specification {
    def "Server should echo message from Client"() {
        when:
        EchoServer server = new EchoServer()
        server.start(4444)
        EchoClient client = new EchoClient()
        client.startConnection("localhost", 4444)

        then:
        client.sendMessage("echo") == "echo"
    }
}

如果我在测试旁边单独运行服务器,并在 Spock 测试的“when:”中注释掉前两行,则测试将成功运行。但是,如果不挂在测试上,我就无法让它运行。

我应该补充一点,我已经研究过 using this guide for Stubbing and Mocking in Java with the Spock Testing Framework 的模拟,但我以前没有模拟的经验,所以我尝试使用它的任何尝试都没有成功,或者知道它是否适用于在这种特殊情况下完全使用模拟。

【问题讨论】:

  • 至于你关于存根/模拟的问题,这取决于你想写什么样的测试。您的示例以及@masooh 提供的答案都是关于集成测试的,即您真正启动服务器获取并阻止端口,然后您正在测试真实客户端和服务器的组合是否有效。如果您想为客户端编写单元测试并为您的代码提供良好的测试覆盖率,您应该模拟服务器而不消耗实际资源,而只是模拟(存根)服务器模拟的答案。
  • @kriegaex 感谢您的建议!

标签: java unit-testing sockets spock


【解决方案1】:

masooh 提供的解决方案有效,但前提是您只启动一个客户端。如果要运行多个测试用例,需要注意只调用一次startConnection(..),否则测试会再次挂起。这是解决集成测试问题的方法:

package de.scrum_master.stackoverflow.q55475971

import spock.lang.Specification
import spock.lang.Unroll

class EchoClientIT extends Specification {
  static final int SERVER_PORT = 4444
  static Thread echoServerThread
  static EchoClient echoClient

  void setupSpec() {
    echoServerThread = Thread.start {
      new EchoServer().start(SERVER_PORT)
    }
    echoClient = new EchoClient()
    echoClient.startConnection("localhost", SERVER_PORT)
  }

  void cleanupSpec() {
    echoServerThread?.stop()
  }

  @Unroll
  def "server echoes client message '#message'"() {
    expect:
    echoClient.sendMessage(message) == message.toString()

    where:
    message << ["echo", "Hello world!", null]
  }
}

您需要手动停止服务器线程并且也无法有序地关闭客户端连接是您的应用程序代码中的问题。您应该通过为客户端和服务器提供关闭/关闭方法来解决这些问题。

如果您想对代码进行单元测试,情况会变得更糟:

  • 无法将套接字依赖项注入客户端,因为它自己创建它,无法从外部访问它并提供用于测试的模拟。
  • 如果您想用单元测试覆盖客户端的异常处理部分并检查正确的行为,您还会注意到从客户端内部的方法调用System.exit(..) 是一个非常糟糕的主意,因为它也会中断测试当它第一次碰到那个部分时。我知道您从 Oracle 示例中复制了您的代码,但它在静态 main(..) 方法中使用,即仅适用于独立应用程序的情况。在那里可以使用它,但在您将其重构为更通用的客户端类之后就不能再使用它了。
  • 一个更普遍的问题是,即使在您的客户端中注释掉System.exit(..),这种情况下的异常处理也只会在控制台上打印一些内容,但会抑制发生的异常,因此客户端类的用户没有简单的方法发现发生了不好的事情并处理这种情况。由于某种原因无法建立连接,她将留下一个无法工作的客户端。您仍然可以调用sendMessage(..),但会出现后续错误。
  • 还有更多的问题,这里我就不提了,因为太详细了。

因此,您希望重构代码以使其更易于维护和测试。这就是测试驱动开发真正有用的地方。它是一种设计工具,而不是主要的质量管理工具。

这个怎么样?我仍然对它不满意,但它显示了测试代码是如何变得更容易的:

回声服务器:

现在的服务器

  • 负责在单独的线程中侦听服务器端口 4444。不再需要在测试中启动额外的线程
  • 为每个传入连接生成另一个新线程
  • 可以同时处理多个连接(另请参见下面的相应集成测试)
  • 具有close() 方法并实现AutoCloseable,即可以手动关闭或通过try-with-resources 关闭。

我还添加了一些日志记录,主要用于演示目的,因为如果测试通过,通常不会记录任何内容。

package de.scrum_master.stackoverflow.q55475971;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class EchoServer implements AutoCloseable {
  private ServerSocket serverSocket;

  public EchoServer(int portNumber) throws IOException {
    this(new ServerSocket(portNumber));
  }

  public EchoServer(ServerSocket serverSocket) throws IOException {
    this.serverSocket = serverSocket;
    listen();
    System.out.printf("%-25s - Echo server started%n", Thread.currentThread());
  }

  private void listen() {
    Runnable listenLoop = () -> {
      System.out.printf("%-25s - Starting echo server listening loop%n", Thread.currentThread());
      while (true) {
        try {
          echo(serverSocket.accept());
        } catch (IOException e) {
          System.out.printf("%-25s - Stopping echo server listening loop%n", Thread.currentThread());
          break;
        }
      }
    };
    new Thread(listenLoop).start();
  }

  private void echo(Socket clientSocket) {
    Runnable echoLoop = () -> {
      System.out.printf("%-25s - Starting echo server echoing loop%n", Thread.currentThread());
      try (
        Socket socket = clientSocket;
        PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))
      ) {
        String inputLine;
        while ((inputLine = in.readLine()) != null) {
          out.println(inputLine);
          System.out.printf("%-25s - Echoing back message: %s%n", Thread.currentThread(), inputLine);
        }
        System.out.printf("%-25s - Stopping echo server echoing loop%n", Thread.currentThread());
      } catch (IOException e) {
        e.printStackTrace();
      }
    };
    new Thread(echoLoop).start();
  }

  @Override
  public void close() throws Exception {
    System.out.printf("%-25s - Shutting down echo server%n", Thread.currentThread());
    if (serverSocket != null) serverSocket.close();
  }
}

回声客户端:

现在的客户

  • 不再吞下异常,而是让它们发生并由用户处理
  • 可以通过其构造函数之一注入Socket 实例,这允许轻松模拟并使类更易于测试
  • 具有close() 方法并实现AutoCloseable,即可以手动关闭或通过try-with-resources 关闭。

我还添加了一些日志记录,主要用于演示目的,因为如果测试通过,通常不会记录任何内容。

package de.scrum_master.stackoverflow.q55475971;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public class EchoClient implements AutoCloseable {
  private Socket echoSocket;
  private PrintWriter out;
  private BufferedReader in;

  public EchoClient(String hostName, int portNumber) throws IOException {
    this(new Socket(hostName, portNumber));
  }

  public EchoClient(Socket echoSocket) throws IOException {
    this.echoSocket = echoSocket;
    out = new PrintWriter(echoSocket.getOutputStream(), true);
    in = new BufferedReader(new InputStreamReader(echoSocket.getInputStream()));
    System.out.printf("%-25s - Echo client started%n", Thread.currentThread());
  }

  public String sendMessage(String msg) throws IOException {
    System.out.printf("%-25s - Sending message: %s%n", Thread.currentThread(), msg);
    out.println(msg);
    return in.readLine();
  }

  @Override
  public void close() throws Exception {
    System.out.printf("%-25s - Shutting down echo client%n", Thread.currentThread());
    if (out != null) out.close();
    if (in != null) in.close();
    if (echoSocket != null) echoSocket.close();
  }
}

集成测试:

这类似于您自己和 masooh 的解决方案,但使用更新的客户端和服务器类。您会看到客户端和服务器现在都可以轻松测试。实际上测试的目的是只测试客户端,使用服务器只是因为它是一个集成测试。但由于这两个类的代码结构现在更加线性,IT 实际上为客户端和服务器创建了 100% 的线路覆盖率。

package de.scrum_master.stackoverflow.q55475971

import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Unroll

class EchoClientIT extends Specification {
  static final int SERVER_PORT = 4444

  @Shared
  EchoClient echoClient
  @Shared
  EchoServer echoServer

  void setupSpec() {
    echoServer = new EchoServer(SERVER_PORT)
    echoClient = new EchoClient("localhost", SERVER_PORT)
  }

  void cleanupSpec() {
    echoClient?.close()
    echoServer?.close()
  }

  @Unroll
  def "server echoes client message '#message'"() {
    expect:
    echoClient.sendMessage(message) == message.toString()

    where:
    message << ["echo", "Hello world!", null]
  }

  def "multiple echo clients"() {
    given:
    def echoClients = [
      new EchoClient("localhost", SERVER_PORT),
      new EchoClient("localhost", SERVER_PORT),
      new EchoClient("localhost", SERVER_PORT)
    ]

    expect:
    echoClients.each {
      assert it.sendMessage("foo") == "foo"
    }
    echoClients.each {
      assert it.sendMessage("bar") == "bar"
    }

    cleanup:
    echoClients.each { it.close() }
  }

  @Unroll
  def "client creation fails with #exceptionType.simpleName when using illegal #connectionInfo"() {
    when:
    new EchoClient(hostName, portNumber)

    then:
    thrown exceptionType

    where:
    connectionInfo | hostName         | portNumber      | exceptionType
    "host name"    | "does.not.exist" | SERVER_PORT     | UnknownHostException
    "port number"  | "localhost"      | SERVER_PORT + 1 | IOException
  }
}

单元测试:

我最后保存了这个,因为你最初的问题是关于嘲笑的。因此,现在我将向您展示如何通过构造函数创建和注入模拟套接字(或者更准确地说是存根)到您的客户端。 IE。单元测试不打开任何真实的端口或套接字,它甚至不使用服务器类。它实际上只对客户端类进行单元测试。甚至抛出的异常都经过测试。

顺便说一句,存根有点复杂,真的表现得像回声服务器。我通过管道流做到了这一点。当然,也可以创建一个只返回固定结果的更简单的模拟/存根。

package de.scrum_master.stackoverflow.q55475971

import spock.lang.Specification
import spock.lang.Unroll

class EchoClientTest extends Specification {
  @Unroll
  def "server echoes client message '#message'"() {
    given:
    def outputStream = new PipedOutputStream()
    def inputStream = new PipedInputStream(outputStream)
    def echoClient = new EchoClient(
      Stub(Socket) {
        getOutputStream() >> outputStream
        getInputStream() >> inputStream
      }
    )

    expect:
    echoClient.sendMessage(message) == message.toString()

    cleanup:
    echoClient.close()

    where:
    message << ["echo", "Hello world!", null]
  }

  def "client creation fails for unreadable socket streams"() {
    when:
    new EchoClient(
      Stub(Socket) {
        getOutputStream() >> { throw new IOException("cannot read output stream") }
        getInputStream() >> { throw new IOException("cannot read input stream") }
      }
    )

    then:
    thrown IOException
  }

  def "client creation fails for unknown host name"() {
    when:
    new EchoClient("does.not.exist", 4444)

    then:
    thrown IOException
  }
}

P.S.:您可以为服务器编写一个类似的单元测试,而不使用客户端类或真正的套接字,但我让您自己弄清楚,但已经准备好服务器类也可以通过构造函数注入接受套接字.但是你会注意到用 mocks 测试服务器的 echo() 方法并不那么容易,所以也许你想在那里进行更多重构。

【讨论】:

  • 非常感谢您提供如此完整而详细的解决方案!
【解决方案2】:

代码的问题是 echoServer.start() 永远不会返回,因为它停留在 while 循环中。因此我会在另一个线程中启动 EchoServer。

class EchoClientTest extends Specification {

    @Shared
    Thread echoServerThread

    void setupSpec() {
        echoServerThread = Thread.start {
            EchoServer server = new EchoServer()
            server.start(4444)
        }
    }

    void cleanupSpec() {
        echoServerThread.stop()
    }

    def "Server should echo message from Client"() {
        when:
        EchoClient client = new EchoClient()
        client.startConnection("localhost", 4444)

        then:
        client.sendMessage("echo") == "echo"
    }
}

请记住,Thread.stop() 已弃用。我只是用它来缩短样本。见https://docs.oracle.com/javase/1.5.0/docs/guide/misc/threadPrimitiveDeprecation.html

【讨论】:

  • 感谢您的回复!在您链接的文档的帮助下,我一直试图让它与您的解决方案一起使用,但经过几次不同的尝试后,它仍然对我有用。线程是我仍然非常缺乏经验的领域。我意识到这是一个稍微独立的问题,但如果你能发布一个完整的工作示例,它真的会有所帮助!
  • 这个测试有什么问题?它与您为客户端 + 服务器对提供的代码完全一致。如果它没有运行,你必须修改了你自己的代码或测试。
  • @kriegaex 我很确定我当时尝试使用确切的代码。 Perhaps it's because I'm using JDK 11? 在尝试使用 masooh 提供的 Oracle 文档中提供的方法时,我可能没有适当地停止线程。
  • 不,Thread.stop() 方法在 Java 11 中仍然有效,我测试了它并看到了源代码。只有Thread.stop(Throwable) 已被删除,有关更多详细信息,请参阅this article。所以这不是你的问题的根本原因,但实际上你必须以某种方式修改测试,正如我所说的。也许您多次致电echoClient.startConnection("localhost", SERVER_PORT) 会使测试再次挂起。
猜你喜欢
  • 1970-01-01
  • 2011-04-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-03-09
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多