使用WatchService编写单元测试代码

7
以下是使用WatchService来使数据与文件同步的简单示例。 我的问题是如何可靠地测试代码。 测试有时会失败,可能是因为操作系统/ JVM将事件放入监视服务中,而测试线程则轮询监视服务之间存在竞争条件。 我希望保持代码简单,单线程和非阻塞,同时也可以进行测试。 我强烈反对在测试代码中加入任意长度的休眠调用。 我希望有更好的解决方案。
public class FileWatcher {

private final WatchService watchService;
private final Path path;
private String data;

public FileWatcher(Path path){
    this.path = path;
    try {
        watchService = FileSystems.getDefault().newWatchService();
        path.toAbsolutePath().getParent().register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
    } catch (Exception ex) {
        throw new RuntimeException(ex);
    }
    load();
}

private void load() {
    try (BufferedReader br = Files.newBufferedReader(path, Charset.defaultCharset())){
        data = br.readLine();
    } catch (IOException ex) {
        data = "";
    }
}

private void update(){
    WatchKey key;
    while ((key=watchService.poll()) != null) {
        for (WatchEvent<?> e : key.pollEvents()) {
            WatchEvent<Path> event = (WatchEvent<Path>) e;
            if (path.equals(event.context())){
                load();
                break;
            }
        }
        key.reset();
    }
}

public String getData(){
    update();
    return data;
}
}

目前的测试情况

public class FileWatcherTest {

public FileWatcherTest() {
}

Path path = Paths.get("myFile.txt");

private void write(String s) throws IOException{
    try (BufferedWriter bw = Files.newBufferedWriter(path, Charset.defaultCharset())) {
        bw.write(s);
    }
}

@Test
public void test() throws IOException{
    for (int i=0; i<100; i++){
        write("hello");
        FileWatcher fw = new FileWatcher(path);
        Assert.assertEquals("hello", fw.getData());
        write("goodbye");
        Assert.assertEquals("goodbye", fw.getData());
    }
}
}
2个回答

5

由于监视服务中正在进行轮询,所以这个时间问题注定会发生。

这个测试并不是一个真正的单元测试,因为它测试了默认文件系统监视器的实际实现。

如果我想要为这个类编写一个自包含的单元测试,我首先需要修改FileWatcher,使其不依赖于默认文件系统。我会通过将WatchService注入到构造函数中,而不是FileSystem来实现这一点。例如...

public class FileWatcher {

    private final WatchService watchService;
    private final Path path;
    private String data;

    public FileWatcher(WatchService watchService, Path path) {
        this.path = path;
        try {
            this.watchService = watchService;
            path.toAbsolutePath().getParent().register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
        load();
    }

    ...

将这个依赖项传递给类,而不是类自己获取WatchService,使这个类在未来更具可重用性。例如,如果您想使用不同的FileSystem实现(如内存中的一个,比如https://github.com/google/jimfs),那么怎么办?
现在,您可以通过模拟依赖项来测试该类,例如...
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static org.fest.assertions.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.nio.file.spi.FileSystemProvider;
import java.util.Arrays;

import org.junit.Before;
import org.junit.Test;

public class FileWatcherTest {

    private FileWatcher fileWatcher;
    private WatchService watchService;

    private Path path;

    @Before
    public void setup() throws Exception {
        // Set up mock watch service and path
        watchService = mock(WatchService.class);

        path = mock(Path.class);

        // Need to also set up mocks for absolute parent path...
        Path absolutePath = mock(Path.class);
        Path parentPath = mock(Path.class);

        // Mock the path's methods...
        when(path.toAbsolutePath()).thenReturn(absolutePath);
        when(absolutePath.getParent()).thenReturn(parentPath);

        // Mock enough of the path so that it can load the test file.
        // On the first load, the loaded data will be "[INITIAL DATA]", any subsequent call it will be "[UPDATED DATA]"
        // (this is probably the smellyest bit of this test...)
        InputStream initialInputStream = createInputStream("[INITIAL DATA]");
        InputStream updatedInputStream = createInputStream("[UPDATED DATA]");
        FileSystem fileSystem = mock(FileSystem.class);
        FileSystemProvider fileSystemProvider = mock(FileSystemProvider.class);

        when(path.getFileSystem()).thenReturn(fileSystem);
        when(fileSystem.provider()).thenReturn(fileSystemProvider);
        when(fileSystemProvider.newInputStream(path)).thenReturn(initialInputStream, updatedInputStream);
        // (end smelly bit)

        // Create the watcher - this should load initial data immediately
        fileWatcher = new FileWatcher(watchService, path);

        // Verify that the watch service was registered with the parent path...
        verify(parentPath).register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
    }

    @Test
    public void shouldReturnCurrentStateIfNoChanges() {
        // Check to see if the initial data is returned if the watch service returns null on poll...
        when(watchService.poll()).thenReturn(null);
        assertThat(fileWatcher.getData()).isEqualTo("[INITIAL DATA]");
    }

    @Test
    public void shouldLoadNewStateIfFileChanged() {
        // Check that the updated data is loaded when the watch service says the path we are interested in has changed on poll... 
        WatchKey watchKey = mock(WatchKey.class);
        @SuppressWarnings("unchecked")
        WatchEvent<Path> pathChangedEvent = mock(WatchEvent.class);

        when(pathChangedEvent.context()).thenReturn(path);
        when(watchKey.pollEvents()).thenReturn(Arrays.asList(pathChangedEvent));
        when(watchService.poll()).thenReturn(watchKey, (WatchKey) null);

        assertThat(fileWatcher.getData()).isEqualTo("[UPDATED DATA]");
    }

    @Test
    public void shouldKeepCurrentStateIfADifferentPathChanged() {
        // Make sure nothing happens if a different path is updated...
        WatchKey watchKey = mock(WatchKey.class);
        @SuppressWarnings("unchecked")
        WatchEvent<Path> pathChangedEvent = mock(WatchEvent.class);

        when(pathChangedEvent.context()).thenReturn(mock(Path.class));
        when(watchKey.pollEvents()).thenReturn(Arrays.asList(pathChangedEvent));
        when(watchService.poll()).thenReturn(watchKey, (WatchKey) null);

        assertThat(fileWatcher.getData()).isEqualTo("[INITIAL DATA]");
    }

    private InputStream createInputStream(String string) {
        return new ByteArrayInputStream(string.getBytes());
    }

}

我能理解你为什么想要一个“真实”的测试,而不是使用模拟 - 在这种情况下它将不是单元测试,你可能别无选择但在检查之间使用sleep(JimFS v1.0代码硬编码为每5秒轮询一次,在核心Java FileSystemWatchService上的轮询时间还没有查看)。
希望这可以帮助到你。

关于“臭味”位 - 我只能说“尽量避免静态调用”!! - 你可以使用 PowerMock(除非完全必要,我会尽量避免使用它) - BretC
也许单元测试这个词不太准确。基本上我想要测试它,包括与文件系统的交互。这只是一个非常简单的例子,但实际使用要复杂得多。我的主要问题是path.register需要一个未记录的私有方法才能工作,这使得模拟变得更加困难。WatchService的功能很棒,但API很糟糕,让我想起了丑陋的遗留代码,而不是最近的基础Java。我想尝试一些东西,如果找不到更好的解决方案,我将接受这个答案并在测试中睡觉。 - user2133814

2

我创建了一个包装器来处理 WatchService 中的许多问题,现在更易于测试。但是,我对 PathWatchService 中的一些并发问题不确定,也没有进行彻底的测试。

新的 FileWatcher:

public class FileWatcher {

    private final PathWatchService pathWatchService;
    private final Path path;
    private String data;

    public FileWatcher(PathWatchService pathWatchService, Path path) {
        this.path = path;
        this.pathWatchService = pathWatchService;
        try {
            this.pathWatchService.register(path.toAbsolutePath().getParent());
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
        load();
    }

    private void load() {
        try (BufferedReader br = Files.newBufferedReader(path, Charset.defaultCharset())){
            data = br.readLine();
        } catch (IOException ex) {
            data = "";
        }
    }

    public void update(){
        PathEvents pe;
        while ((pe=pathWatchService.poll()) != null) {
            for (WatchEvent we : pe.getEvents()){
                if (path.equals(we.context())){
                    load();
                    return;
                }
            }
        }
    }

    public String getData(){
        update();
        return data;
    }
}

包装器:

public class PathWatchService implements AutoCloseable {

    private final WatchService watchService;
    private final BiMap<WatchKey, Path> watchKeyToPath = HashBiMap.create();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Queue<WatchKey> invalidKeys = new ConcurrentLinkedQueue<>();

    /**
     * Constructor.
     */
    public PathWatchService() {
        try {
            watchService = FileSystems.getDefault().newWatchService();
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    /**
     * Register the input path with the WatchService for all
     * StandardWatchEventKinds. Registering a path which is already being
     * watched has no effect.
     *
     * @param path
     * @return
     * @throws IOException
     */
    public void register(Path path) throws IOException {
        register(path, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
    }

    /**
     * Register the input path with the WatchService for the input event kinds.
     * Registering a path which is already being watched has no effect.
     *
     * @param path
     * @param kinds
     * @return
     * @throws IOException
     */
    public void register(Path path, WatchEvent.Kind... kinds) throws IOException {
        try {
            lock.writeLock().lock();
            removeInvalidKeys();
            WatchKey key = watchKeyToPath.inverse().get(path);
            if (key == null) {
                key = path.register(watchService, kinds);
                watchKeyToPath.put(key, path);
            }
        } finally {
            lock.writeLock().unlock();
        }
    }

    /**
     * Close the WatchService.
     *
     * @throws IOException
     */
    @Override
    public void close() throws IOException {
        try {
            lock.writeLock().lock();
            watchService.close();
            watchKeyToPath.clear();
            invalidKeys.clear();
        } finally {
            lock.writeLock().unlock();
        }
    }

    /**
     * Retrieves and removes the next PathEvents object, or returns null if none
     * are present.
     *
     * @return
     */
    public PathEvents poll() {
        return keyToPathEvents(watchService.poll());
    }

    /**
     * Return a PathEvents object from the input key.
     *
     * @param key
     * @return
     */
    private PathEvents keyToPathEvents(WatchKey key) {
        if (key == null) {
            return null;
        }
        try {
            lock.readLock().lock();
            Path watched = watchKeyToPath.get(key);
            List<WatchEvent<Path>> events = new ArrayList<>();
            for (WatchEvent e : key.pollEvents()) {
                events.add((WatchEvent<Path>) e);
            }
            boolean isValid = key.reset();
            if (isValid == false) {
                invalidKeys.add(key);
            }
            return new PathEvents(watched, events, isValid);
        } finally {
            lock.readLock().unlock();
        }
    }

    /**
     * Retrieves and removes the next PathEvents object, waiting if necessary up
     * to the specified wait time, returns null if none are present after the
     * specified wait time.
     *
     * @return
     */
    public PathEvents poll(long timeout, TimeUnit unit) throws InterruptedException {
        return keyToPathEvents(watchService.poll(timeout, unit));
    }

    /**
     * Retrieves and removes the next PathEvents object, waiting if none are yet
     * present.
     *
     * @return
     */
    public PathEvents take() throws InterruptedException {
        return keyToPathEvents(watchService.take());
    }

    /**
     * Get all paths currently being watched. Any paths which were watched but
     * have invalid keys are not returned.
     *
     * @return
     */
    public Set<Path> getWatchedPaths() {
        try {
            lock.readLock().lock();
            Set<Path> paths = new HashSet<>(watchKeyToPath.inverse().keySet());
            WatchKey key;
            while ((key = invalidKeys.poll()) != null) {
                paths.remove(watchKeyToPath.get(key));
            }
            return paths;
        } finally {
            lock.readLock().unlock();
        }
    }

    /**
     * Cancel watching the specified path. Cancelling a path which is not being
     * watched has no effect.
     *
     * @param path
     */
    public void cancel(Path path) {
        try {
            lock.writeLock().lock();
            removeInvalidKeys();
            WatchKey key = watchKeyToPath.inverse().remove(path);
            if (key != null) {
                key.cancel();
            }
        } finally {
            lock.writeLock().unlock();
        }
    }

    /**
     * Removes any invalid keys from internal data structures. Note this
     * operation is also performed during register and cancel calls.
     */
    public void cleanUp() {
        try {
            lock.writeLock().lock();
            removeInvalidKeys();
        } finally {
            lock.writeLock().unlock();
        }
    }

    /**
     * Clean up method to remove invalid keys, must be called from inside an
     * acquired write lock.
     */
    private void removeInvalidKeys() {
        WatchKey key;
        while ((key = invalidKeys.poll()) != null) {
            watchKeyToPath.remove(key);
        }
    }
}

数据类:

public class PathEvents {

    private final Path watched;
    private final ImmutableList<WatchEvent<Path>> events;
    private final boolean isValid;

    /**
     * Constructor.
     * 
     * @param watched
     * @param events
     * @param isValid 
     */
    public PathEvents(Path watched, List<WatchEvent<Path>> events, boolean isValid) {
        this.watched = watched;
        this.events = ImmutableList.copyOf(events);
        this.isValid = isValid;
    }

    /**
     * Return an immutable list of WatchEvent's.
     * @return 
     */
    public List<WatchEvent<Path>> getEvents() {
        return events;
    }

    /**
     * True if the watched path is valid.
     * @return 
     */
    public boolean isIsValid() {
        return isValid;
    }

    /**
     * Return the path being watched in which these events occurred.
     * 
     * @return 
     */
    public Path getWatched() {
        return watched;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final PathEvents other = (PathEvents) obj;
        if (!Objects.equals(this.watched, other.watched)) {
            return false;
        }
        if (!Objects.equals(this.events, other.events)) {
            return false;
        }
        if (this.isValid != other.isValid) {
            return false;
        }
        return true;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 71 * hash + Objects.hashCode(this.watched);
        hash = 71 * hash + Objects.hashCode(this.events);
        hash = 71 * hash + (this.isValid ? 1 : 0);
        return hash;
    }

    @Override
    public String toString() {
        return "PathEvents{" + "watched=" + watched + ", events=" + events + ", isValid=" + isValid + '}';
    }
}

最后进行测试,注意这不是完整的单元测试,但演示了如何为此情况编写测试。

public class FileWatcherTest {

    public FileWatcherTest() {
    }
    Path path = Paths.get("myFile.txt");
    Path parent = path.toAbsolutePath().getParent();

    private void write(String s) throws IOException {
        try (BufferedWriter bw = Files.newBufferedWriter(path, Charset.defaultCharset())) {
            bw.write(s);
        }
    }

    @Test
    public void test() throws IOException, InterruptedException{
        write("hello");

        PathWatchService real = new PathWatchService();
        real.register(parent);
        PathWatchService mock = mock(PathWatchService.class);

        FileWatcher fileWatcher = new FileWatcher(mock, path);
        verify(mock).register(parent);
        Assert.assertEquals("hello", fileWatcher.getData());

        write("goodbye");
        PathEvents pe = real.poll(10, TimeUnit.SECONDS);
        if (pe == null){
            Assert.fail("Should have an event for writing good bye");
        }
        when(mock.poll()).thenReturn(pe).thenReturn(null);

        Assert.assertEquals("goodbye", fileWatcher.getData());
    }
}

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