使用模拟用户输入进行JUnit测试

97

我正在尝试为一个需要用户输入的方法创建一些JUnit测试。被测试的方法看起来有点像下面这个方法:

public static int testUserInput() {
    Scanner keyboard = new Scanner(System.in);
    System.out.println("Give a number between 1 and 10");
    int input = keyboard.nextInt();

    while (input < 1 || input > 10) {
        System.out.println("Wrong number, try again.");
        input = keyboard.nextInt();
    }

    return input;
}

在JUnit测试方法中,是否有可能自动传递一个int值给程序,而不是由我或其他人手动输入?就像模拟用户输入一样。


7
你具体是在测试什么?扫描器吗?一个测试方法通常应该断言某些东西,以便于检验其有效性。 - Markus
2
你不需要测试Java的Scanner类。你可以手动设置输入并简单地测试自己的逻辑。int input = -1或5或11将覆盖您的逻辑。 - blong824
3
六年后,这仍然是一个好问题。尤其是因为在开始开发应用程序时,您通常可能不希望拥有JavaFX的所有花哨功能,而是只想使用简单的命令行进行一些非常基本的交互。遗憾的是,JUnit并没有让这变得更容易。对我来说,Omar Elshal的答案非常好,涉及到最少的“人为”或“扭曲”的应用程序编码... - mike rodent
7个回答

122

您可以通过调用System.setIn(InputStream in)将System.in替换为自己的流。InputStream可以是字节数组:

InputStream sysInBackup = System.in; // backup System.in to restore it later
ByteArrayInputStream in = new ByteArrayInputStream("My string".getBytes());
System.setIn(in);

// do your thing

// optionally, reset System.in to its original
System.setIn(sysInBackup);

通过将IN和OUT作为参数传递,可以采用不同的方法使此方法更易于测试:

public static int testUserInput(InputStream in,PrintStream out) {
    Scanner keyboard = new Scanner(in);
    out.println("Give a number between 1 and 10");
    int input = keyboard.nextInt();

    while (input < 1 || input > 10) {
        out.println("Wrong number, try again.");
        input = keyboard.nextInt();
    }

    return input;
}

7
如果我有多个输入,该怎么做呢?比如说,在//do your thing这个位置我要求用户提供三个提示。那我该怎么做呢? - Stupid.Fat.Cat
1
@Stupid.Fat.Cat 我已经添加了第二种方法来解决问题,更加优雅和灵活。 - KrzyH
1
对于第二种方法,您需要在方法定义中将输入流和输出流作为参数传递,并且这不是我想要的。您知道有更好的方法吗? - Chirag
6
如何模拟按下回车键?目前,程序只是一次性读取所有输入,这样不好。我尝试了\n,但没有任何区别。 - CodyBugstein
5
使用ByteArrayInputStream in = new ByteArrayInputStream(("1" + System.lineSeparator() + "2").getBytes());获取多个输入,然后分别用于不同的 keyboard.nextLine()调用。 - A Jar of Clay
显示剩余3条评论

21

要测试你的代码,你应该为系统输入/输出函数创建一个包装器。你可以使用依赖注入来实现这一点,从而给我们提供一个可以请求新整数的类:

public static class IntegerAsker {
    private final Scanner scanner;
    private final PrintStream out;

    public IntegerAsker(InputStream in, PrintStream out) {
        scanner = new Scanner(in);
        this.out = out;
    }

    public int ask(String message) {
        out.println(message);
        return scanner.nextInt();
    }
}

然后,您可以使用模拟框架(我使用Mockito)为函数创建测试:

@Test
public void getsIntegerWhenWithinBoundsOfOneToTen() throws Exception {
    IntegerAsker asker = mock(IntegerAsker.class);
    when(asker.ask(anyString())).thenReturn(3);

    assertEquals(getBoundIntegerFromUser(asker), 3);
}

@Test
public void asksForNewIntegerWhenOutsideBoundsOfOneToTen() throws Exception {
    IntegerAsker asker = mock(IntegerAsker.class);
    when(asker.ask("Give a number between 1 and 10")).thenReturn(99);
    when(asker.ask("Wrong number, try again.")).thenReturn(3);

    getBoundIntegerFromUser(asker);

    verify(asker).ask("Wrong number, try again.");
}

然后编写通过测试的函数。由于您可以消除请求/获取整数重复,并封装实际的系统调用,因此该函数会更加简洁。

public static void main(String[] args) {
    getBoundIntegerFromUser(new IntegerAsker(System.in, System.out));
}

public static int getBoundIntegerFromUser(IntegerAsker asker) {
    int input = asker.ask("Give a number between 1 and 10");
    while (input < 1 || input > 10)
        input = asker.ask("Wrong number, try again.");
    return input;
}

这可能对于您的小例子来说似乎有些过度,但如果您正在构建一个更大的应用程序,并且像这样开发,很快就能得到回报。


6

测试类似代码的一种常见方法是提取一个方法,该方法接受Scanner和PrintWriter参数,类似于这个StackOverflow回答,并对其进行测试:

public void processUserInput() {
  processUserInput(new Scanner(System.in), System.out);
}

/** For testing. Package-private if possible. */
public void processUserInput(Scanner scanner, PrintWriter output) {
  output.println("Give a number between 1 and 10");
  int input = scanner.nextInt();

  while (input < 1 || input > 10) {
    output.println("Wrong number, try again.");
    input = scanner.nextInt();
  }

  return input;
}

请注意,直到结束您将无法读取输出,并且您需要事先指定所有输入。
@Test
public void shouldProcessUserInput() {
  StringWriter output = new StringWriter();
  String input = "11\n"       // "Wrong number, try again."
               + "10\n";

  assertEquals(10, systemUnderTest.processUserInput(
      new Scanner(input), new PrintWriter(output)));

  assertThat(output.toString(), contains("Wrong number, try again.")););
}

当然,你也可以将“scanner”和“output”作为可变字段保留在系统测试中,而不是创建一个过载方法。我倾向于尽可能保持类的无状态性,但如果这对你或你的同事/教师很重要,那么这并不是一个非常大的让步。
你还可以选择将测试代码放在与被测试代码相同的Java包中(即使它在不同的源文件夹中),这样可以放宽两个参数过载的可见性为包私有。

你是想进行测试,所以调用了方法processUserInput()中的method(in, out),而不是processUserInput(in, out)? - NadZ
@NadZ 当然;我先用一个通用的例子开始,然后没有完全改回来以便回答这个问题。 - Jeff Bowman

4
我找到了一种更简单的方法。但是,你需要使用@Stefan Birkner的外部库System.rules
我只是采用了那里提供的示例,我认为它不能再更简单了。
import java.util.Scanner;

public class Summarize {
  public static int sumOfNumbersFromSystemIn() {
    Scanner scanner = new Scanner(System.in);
    int firstSummand = scanner.nextInt();
    int secondSummand = scanner.nextInt();
    return firstSummand + secondSummand;
  }
}

测试

import static org.junit.Assert.*;
import static org.junit.contrib.java.lang.system.TextFromStandardInputStream.*;

import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.TextFromStandardInputStream;

public class SummarizeTest {
  @Rule
  public final TextFromStandardInputStream systemInMock
    = emptyStandardInputStream();

  @Test
  public void summarizesTwoNumbers() {
    systemInMock.provideLines("1", "2");
    assertEquals(3, Summarize.sumOfNumbersFromSystemIn());
  }
}

然而,在我的情况下,我的第二个输入有空格,这使得整个输入流为空!


这个很好用...我不知道你所说的“输入流为空”的意思。好的是,当使用System.in时,inputEntry = scanner.nextLine();总是会等待用户(并且将空行接受为一个空的String)...而当您使用systemInMock.provideLines()提供行时,当行用完时会抛出NoSuchElementException。这确实使得不需要过多地“扭曲”应用程序代码来适应测试变得容易。 - mike rodent
我不太记得问题具体是什么了,但你说得对,我再次检查了我的代码并注意到我用了两种方法来解决它:
  1. 使用 systemInMock: systemInMock.provideLines("1", "2");
  2. 使用 System.setIn 而不需要外部库: String data2 = "1 2"; System.setIn(new ByteArrayInputStream(data2.getBytes()));
- Omar Elshal

3

我已经解决了从stdin读取以模拟控制台的问题...

我的问题是,我想在JUnit测试中写入控制台以创建特定对象...

问题就像你们所说的:如何从JUnit测试中写入Stdin?

然后在大学里,我学习了像你们所说的重定向,比如System.setIn(InputStream)更改stdin文件描述符,然后你可以在其中写入...

但是还有一个问题要解决... JUnit测试块等待从新InputStream读取,因此您需要创建一个线程来从InputStream读取,并从JUnit测试线程写入新的Stdin...首先您必须写入Stdin,因为如果您稍后写入从stdin读取的线程,则可能会出现竞争条件...您可以在读取之前写入InputStream,也可以在写入之前从InputStream读取...

这是我的代码,我的英语水平很差,希望你们都能理解问题和从JUnit测试中模拟写入stdin的解决方案。

private void readFromConsole(String data) throws InterruptedException {
    System.setIn(new ByteArrayInputStream(data.getBytes()));

    Thread rC = new Thread() {
        @Override
        public void run() {
            study = new Study();
            study.read(System.in);
        }
    };
    rC.start();
    rC.join();      
}

3
你可以先将从键盘中获取数字的逻辑提取出来成为一个方法。这样你就可以在不担心键盘输入的情况下测试验证逻辑。如果想要测试键盘.nextInt()方法的调用,可以考虑使用模拟对象。

2
注意,您不能对Scanner对象进行模拟(它们是final的)。 - hoipolloi

1

我发现创建一个类似于java.io.Console的定义方法的接口并将其用于读取或写入System.out很有帮助。实际的实现将委托给System.console(),而您的JUnit版本可以是具有预设输入和预期响应的模拟对象。

例如,你可以构造一个MockConsole,其中包含用户输入的预设内容。当调用readLine时,模拟实现会从列表中弹出一个输入字符串。它还将收集所有写入输出的响应列表。在测试结束时,如果一切顺利,则所有输入都已读取,您可以对输出进行断言。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接