2015-04-18 115 views
5

下面是使用WatchService保持數據與文件同步的簡單示例。我的問題是如何可靠地測試代碼。測試偶爾會失敗,可能是因爲os/jvm將事件帶入watch服務和測試線程輪詢watch服務之間的競爭條件。我的願望是保持代碼簡單,單線程和非阻塞,但也是可測試的。我強烈不喜歡把任意長度的睡眠調用放入測試代碼中。我希望有更好的解決方案。帶WatchService的單元測試代碼

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()); 
    } 
} 
} 

回答

1

此計時問題,勢必因爲輪詢表服務發生的事情發生。

這個測試不是真正的單元測試,因爲它測試了默認文件系統觀察器的實際實現。

如果我想爲這個類做一個自包含的單元測試,我會先修改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的輪詢時間)

希望這會有幫助

+0

關於「臭」的一點 - 我只能說「儘量避免靜電」! - 你總是可以使用'PowerMock'(我試圖避免,除非完全有必要) – BretC

+0

也許單元測試是錯誤的詞。基本上我想測試它,包括與文件系統的交互。這是一個非常簡單的例子,但真正的使用比較複雜。我的主要問題是path.register需要一個無證的魔法私人方法來工作,這使得嘲諷更加困難。 WatchService的功能非常棒,但API非常糟糕,讓我想起了糟糕的遺留代碼,而不是最近的基礎Java。我想嘗試一些事情,如果我不能做得更好,我會接受這個答案,並在測試中入睡。 – user2133814

2

我在WatchService上創建了一個包裝器來清除我在API中遇到的許多問題。現在它更容易測試。我不確定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()); 
    } 
}