Merge "Do not wrap nav links in unified diff view" into stable-2.12
diff --git a/gerrit-httpd/BUCK b/gerrit-httpd/BUCK
index da224d5..3085054 100644
--- a/gerrit-httpd/BUCK
+++ b/gerrit-httpd/BUCK
@@ -58,6 +58,8 @@
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
     '//gerrit-util-http:http',
+    '//gerrit-util-http:testutil',
+    '//lib:jimfs',
     '//lib:junit',
     '//lib:gson',
     '//lib:gwtorm',
@@ -69,6 +71,7 @@
     '//lib/guice:guice-servlet',
     '//lib/jgit:jgit',
     '//lib/jgit:junit',
+    '//lib/joda:joda-time',
   ],
   source_under_test = [':httpd'],
   # TODO(sop) Remove after Buck supports Eclipse
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 507bc59..6f7ad51 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -26,6 +26,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableMap;
@@ -96,10 +97,18 @@
 
   private final Cache<Path, Resource> cache;
   private final boolean refresh;
+  private final int cacheFileSizeLimitBytes;
 
   protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh) {
+    this(cache, refresh, CACHE_FILE_SIZE_LIMIT_BYTES);
+  }
+
+  @VisibleForTesting
+  ResourceServlet(Cache<Path, Resource> cache, boolean refresh,
+      int cacheFileSizeLimitBytes) {
     this.cache = checkNotNull(cache, "cache");
     this.refresh = refresh;
+    this.cacheFileSizeLimitBytes = cacheFileSizeLimitBytes;
   }
 
   /**
@@ -214,7 +223,7 @@
   private boolean maybeStream(Path p, HttpServletRequest req,
       HttpServletResponse rsp) throws IOException {
     try {
-      if (Files.size(p) < CACHE_FILE_SIZE_LIMIT_BYTES) {
+      if (Files.size(p) < cacheFileSizeLimitBytes) {
         return false;
       }
     } catch (NoSuchFileException e) {
@@ -294,7 +303,12 @@
     }
 
     boolean isStale(Path p, ResourceServlet rs) throws IOException {
-      FileTime t = rs.getLastModifiedTime(p);
+      FileTime t;
+      try {
+        t = rs.getLastModifiedTime(p);
+      } catch (NoSuchFileException e) {
+        return this != NOT_FOUND;
+      }
       return t.toMillis() == 0
           || lastModified.toMillis() == 0
           || !lastModified.equals(t);
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 10a35a8..94f3768 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import com.google.inject.Key;
 import com.google.inject.util.Providers;
 
@@ -88,8 +90,8 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req = ems.createMock(HttpServletRequest.class);
-    HttpServletResponse res = ems.createMock(HttpServletResponse.class);
+    HttpServletRequest req = new FakeHttpServletRequest();
+    HttpServletResponse res = new FakeHttpServletResponse();
 
     FilterChain chain = ems.createMock(FilterChain.class);
     chain.doFilter(req, res);
@@ -110,8 +112,8 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock("config", FilterConfig.class);
-    HttpServletRequest req = ems.createMock("req", HttpServletRequest.class);
-    HttpServletResponse res = ems.createMock("res", HttpServletResponse.class);
+    HttpServletRequest req = new FakeHttpServletRequest();
+    HttpServletResponse res = new FakeHttpServletResponse();
 
     FilterChain chain = ems.createMock("chain", FilterChain.class);
 
@@ -137,8 +139,8 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req = ems.createMock(HttpServletRequest.class);
-    HttpServletResponse res = ems.createMock(HttpServletResponse.class);
+    HttpServletRequest req = new FakeHttpServletRequest();
+    HttpServletResponse res = new FakeHttpServletResponse();
 
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
@@ -169,8 +171,8 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req = ems.createMock(HttpServletRequest.class);
-    HttpServletResponse res = ems.createMock(HttpServletResponse.class);
+    HttpServletRequest req = new FakeHttpServletRequest();
+    HttpServletResponse res = new FakeHttpServletResponse();
 
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
@@ -202,8 +204,8 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req = ems.createMock(HttpServletRequest.class);
-    HttpServletResponse res = ems.createMock(HttpServletResponse.class);
+    HttpServletRequest req = new FakeHttpServletRequest();
+    HttpServletResponse res = new FakeHttpServletResponse();
 
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
@@ -242,10 +244,10 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req1 = ems.createMock("req1", HttpServletRequest.class);
-    HttpServletRequest req2 = ems.createMock("req2", HttpServletRequest.class);
-    HttpServletResponse res1 = ems.createMock("res1", HttpServletResponse.class);
-    HttpServletResponse res2 = ems.createMock("res2", HttpServletResponse.class);
+    HttpServletRequest req1 = new FakeHttpServletRequest();
+    HttpServletRequest req2 = new FakeHttpServletRequest();
+    HttpServletResponse res1 = new FakeHttpServletResponse();
+    HttpServletResponse res2 = new FakeHttpServletResponse();
 
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock("chain", FilterChain.class);
@@ -294,12 +296,12 @@
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
-    HttpServletRequest req1 = ems.createMock("req1", HttpServletRequest.class);
-    HttpServletRequest req2 = ems.createMock("req2", HttpServletRequest.class);
-    HttpServletRequest req3 = ems.createMock("req3", HttpServletRequest.class);
-    HttpServletResponse res1 = ems.createMock("res1", HttpServletResponse.class);
-    HttpServletResponse res2 = ems.createMock("res2", HttpServletResponse.class);
-    HttpServletResponse res3 = ems.createMock("res3", HttpServletResponse.class);
+    HttpServletRequest req1 = new FakeHttpServletRequest();
+    HttpServletRequest req2 = new FakeHttpServletRequest();
+    HttpServletRequest req3 = new FakeHttpServletRequest();
+    HttpServletResponse res1 = new FakeHttpServletResponse();
+    HttpServletResponse res2 = new FakeHttpServletResponse();
+    HttpServletResponse res3 = new FakeHttpServletResponse();
 
     Plugin plugin = ems.createMock(Plugin.class);
 
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
index e032823..9559e13 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.httpd.plugins;
 
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 
 import org.junit.Test;
 
@@ -34,16 +33,17 @@
     ContextMapper classUnderTest = new ContextMapper(CONTEXT);
 
     HttpServletRequest originalRequest =
-        createMockRequest("/plugins/", PLUGIN_NAME + "/" + RESOURCE);
+        createFakeRequest("/plugins/", PLUGIN_NAME + "/" + RESOURCE);
 
     HttpServletRequest result =
         classUnderTest.create(originalRequest, PLUGIN_NAME);
 
-    assertEquals(CONTEXT + "/plugins/" + PLUGIN_NAME, result.getContextPath());
-    assertEquals("", result.getServletPath());
-    assertEquals("/" + RESOURCE, result.getPathInfo());
-    assertEquals(CONTEXT + "/plugins/" + PLUGIN_NAME + "/" + RESOURCE,
-        result.getRequestURI());
+    assertThat(result.getContextPath())
+        .isEqualTo(CONTEXT + "/plugins/" + PLUGIN_NAME);
+    assertThat(result.getServletPath()).isEqualTo("");
+    assertThat(result.getPathInfo()).isEqualTo("/" + RESOURCE);
+    assertThat(result.getRequestURI())
+        .isEqualTo(CONTEXT + "/plugins/" + PLUGIN_NAME + "/" + RESOURCE);
   }
 
   @Test
@@ -51,29 +51,22 @@
     ContextMapper classUnderTest = new ContextMapper(CONTEXT);
 
     HttpServletRequest originalRequest =
-        createMockRequest("/a/plugins/", PLUGIN_NAME + "/" + RESOURCE);
+        createFakeRequest("/a/plugins/", PLUGIN_NAME + "/" + RESOURCE);
 
     HttpServletRequest result =
         classUnderTest.create(originalRequest, PLUGIN_NAME);
 
-    assertEquals(CONTEXT + "/a/plugins/" + PLUGIN_NAME,
-        result.getContextPath());
-    assertEquals("", result.getServletPath());
-    assertEquals("/" + RESOURCE, result.getPathInfo());
-    assertEquals(CONTEXT + "/a/plugins/" + PLUGIN_NAME + "/" + RESOURCE,
-        result.getRequestURI());
+    assertThat(result.getContextPath())
+        .isEqualTo(CONTEXT + "/a/plugins/" + PLUGIN_NAME);
+    assertThat(result.getServletPath()).isEqualTo("");
+    assertThat(result.getPathInfo()).isEqualTo("/" + RESOURCE);
+    assertThat(result.getRequestURI())
+        .isEqualTo(CONTEXT + "/a/plugins/" + PLUGIN_NAME + "/" + RESOURCE);
   }
 
-  private static HttpServletRequest createMockRequest(String servletPath,
+  private static FakeHttpServletRequest createFakeRequest(String servletPath,
       String pathInfo) {
-    HttpServletRequest req = createNiceMock(HttpServletRequest.class);
-    expect(req.getContextPath()).andStubReturn(CONTEXT);
-    expect(req.getServletPath()).andStubReturn(servletPath);
-    expect(req.getPathInfo()).andStubReturn(pathInfo);
-    String uri = CONTEXT + servletPath + pathInfo;
-    expect(req.getRequestURI()).andStubReturn(uri);
-    replay(req);
-
-    return req;
+    return new FakeHttpServletRequest(
+        "gerrit.example.com", 80, CONTEXT, servletPath).setPathInfo(pathInfo);
   }
 }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
new file mode 100644
index 0000000..2ddd4eef
--- /dev/null
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -0,0 +1,337 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.Lists;
+import com.google.common.io.ByteStreams;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+
+import org.joda.time.format.ISODateTimeFormat;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.zip.GZIPInputStream;
+
+public class ResourceServletTest {
+  private static Cache<Path, Resource> newCache(int size) {
+    return CacheBuilder.newBuilder()
+      .maximumSize(size)
+      .recordStats()
+      .build();
+  }
+
+  private static class Servlet extends ResourceServlet {
+    private static final long serialVersionUID = 1L;
+
+    private final FileSystem fs;
+
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
+        boolean refresh) {
+      super(cache, refresh);
+      this.fs = fs;
+    }
+
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
+        boolean refresh, int cacheFileSizeLimitBytes) {
+      super(cache, refresh, cacheFileSizeLimitBytes);
+      this.fs = fs;
+    }
+
+    @Override
+    protected Path getResourcePath(String pathInfo) {
+      return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
+    }
+  }
+
+  private FileSystem fs;
+  private AtomicLong ts;
+
+  @Before
+  public void setUp() {
+    fs = Jimfs.newFileSystem(Configuration.unix());
+    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis(
+        "2010-01-30T12:00:00.000-08:00"));
+  }
+
+  @Test
+  public void notFoundWithoutRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false);
+
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 0, 1);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 1);
+  }
+
+  @Test
+  public void notFoundWithRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 0, 1);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/notfound"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
+    assertNotCacheable(res);
+    assertCacheHits(cache, 1, 1);
+  }
+
+  @Test
+  public void smallFileWithRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo2");
+    assertCacheable(res, true);
+    assertHasETag(res);
+    // Hit, invalidate, miss.
+    assertCacheHits(cache, 2, 3);
+  }
+
+  @Test
+  public void smallFileWithoutRefresh() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, false);
+
+    writeFile("/foo", "foo1");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    // Miss on getIfPresent, miss on get.
+    assertCacheHits(cache, 0, 2);
+
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    assertCacheHits(cache, 1, 2);
+
+    writeFile("/foo", "foo2");
+    res = new FakeHttpServletResponse();
+    servlet.doGet(request("/foo"), res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertCacheable(res, false);
+    assertHasETag(res);
+    assertCacheHits(cache, 2, 2);
+  }
+
+  @Test
+  public void verySmallFileDoesntBotherWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+    writeFile("/foo", "foo1");
+
+    FakeHttpServletRequest req = request("/foo")
+        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isNull();
+    assertThat(res.getActualBodyString()).isEqualTo("foo1");
+    assertHasETag(res);
+    assertCacheable(res, true);
+  }
+
+  @Test
+  public void smallFileWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true);
+    String content = Strings.repeat("a", 100);
+    writeFile("/foo", content);
+
+    FakeHttpServletRequest req = request("/foo")
+        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
+    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
+    assertHasETag(res);
+    assertCacheable(res, true);
+  }
+
+  @Test
+  public void largeFileBypassesCacheRegardlessOfRefreshParamter()
+      throws Exception {
+    for (boolean refresh : Lists.newArrayList(true, false)) {
+      Cache<Path, Resource> cache = newCache(1);
+      Servlet servlet = new Servlet(fs, cache, refresh, 3);
+
+      writeFile("/foo", "foo1");
+      FakeHttpServletResponse res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo1");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 1);
+
+      writeFile("/foo", "foo1");
+      res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo1");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 2);
+
+      writeFile("/foo", "foo2");
+      res = new FakeHttpServletResponse();
+      servlet.doGet(request("/foo"), res);
+      assertThat(res.getStatus()).isEqualTo(SC_OK);
+      assertThat(res.getActualBodyString()).isEqualTo("foo2");
+      assertThat(res.getHeader("Last-Modified")).isNotNull();
+      assertCacheable(res, refresh);
+      assertHasLastModified(res);
+      assertCacheHits(cache, 0, 3);
+    }
+  }
+
+  @Test
+  public void largeFileWithGzip() throws Exception {
+    Cache<Path, Resource> cache = newCache(1);
+    Servlet servlet = new Servlet(fs, cache, true, 3);
+    String content = Strings.repeat("a", 100);
+    writeFile("/foo", content);
+
+    FakeHttpServletRequest req = request("/foo")
+        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.doGet(req, res);
+    assertThat(res.getStatus()).isEqualTo(SC_OK);
+    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
+    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
+    assertHasLastModified(res);
+    assertCacheable(res, true);
+  }
+
+  // TODO(dborowitz): Check MIME type.
+  // TODO(dborowitz): Test that JS is not gzipped.
+  // TODO(dborowitz): Test ?e parameter.
+  // TODO(dborowitz): Test If-None-Match behavior.
+  // TODO(dborowitz): Test If-Modified-Since behavior.
+
+  private void writeFile(String path, String content) throws Exception {
+    Files.write(fs.getPath(path), content.getBytes(UTF_8));
+    Files.setLastModifiedTime(
+        fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
+  }
+
+  private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
+    assertThat(cache.stats().hitCount()).named("hits").isEqualTo(hits);
+    assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
+  }
+
+  private static void assertCacheable(FakeHttpServletResponse res,
+      boolean revalidate) {
+    String header = res.getHeader("Cache-Control").toLowerCase();
+    assertThat(header).contains("public");
+    if (revalidate) {
+      assertThat(header).contains("must-revalidate");
+    } else {
+      assertThat(header).doesNotContain("must-revalidate");
+    }
+  }
+
+  private static void assertHasLastModified(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("Last-Modified")).isNotNull();
+    assertThat(res.getHeader("ETag")).isNull();
+  }
+
+  private static void assertHasETag(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("ETag")).isNotNull();
+    assertThat(res.getHeader("Last-Modified")).isNull();
+  }
+
+  private static void assertNotCacheable(FakeHttpServletResponse res) {
+    assertThat(res.getHeader("Cache-Control")).contains("no-cache");
+    assertThat(res.getHeader("ETag")).isNull();
+    assertThat(res.getHeader("Last-Modified")).isNull();
+  }
+
+  private static FakeHttpServletRequest request(String path) {
+    return new FakeHttpServletRequest().setPathInfo(path);
+  }
+
+  private static String gunzip(byte[] data) throws Exception {
+    try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) {
+      return new String(ByteStreams.toByteArray(in), UTF_8);
+    }
+  }
+}
diff --git a/gerrit-util-http/BUCK b/gerrit-util-http/BUCK
index e770f60..e9b2a3d 100644
--- a/gerrit-util-http/BUCK
+++ b/gerrit-util-http/BUCK
@@ -5,11 +5,30 @@
   visibility = ['PUBLIC'],
 )
 
+TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
+
+java_library(
+  name = 'testutil',
+  srcs = TESTUTIL_SRCS,
+  deps = [
+    '//gerrit-extension-api:api',
+    '//lib:guava',
+    '//lib:servlet-api-3_1',
+    '//lib/httpcomponents:httpclient',
+    '//lib/jgit:jgit',
+  ],
+  visibility = ['PUBLIC'],
+)
+
 java_test(
   name = 'http_tests',
-  srcs = glob(['src/test/java/**/*.java']),
+  srcs = glob(
+    ['src/test/java/**/*.java'],
+    excludes = TESTUTIL_SRCS,
+  ),
   deps = [
     ':http',
+    ':testutil',
     '//lib:junit',
     '//lib:servlet-api-3_1',
     '//lib:truth',
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
index 42fcb16..e656e56 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
@@ -15,77 +15,50 @@
 package com.google.gerrit.util.http;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
 
-import org.junit.After;
-import org.junit.Before;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import javax.servlet.http.HttpServletRequest;
-
 public class RequestUtilTest {
-  private List<Object> mocks;
-
-  @Before
-  public void setUp() {
-    mocks = Collections.synchronizedList(new ArrayList<>());
-  }
-
-  @After
-  public void tearDown() {
-    for (Object mock : mocks) {
-      verify(mock);
-    }
-  }
-
   @Test
   public void emptyContextPath() {
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/s/foo/bar", "", "/s"))).isEqualTo("/foo/bar");
+        fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/s/foo%2Fbar", "", "/s"))).isEqualTo("/foo%2Fbar");
+        fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
   public void emptyServletPath() {
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/foo/bar", "", "/c"))).isEqualTo("/foo/bar");
+        fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/foo%2Fbar", "", "/c"))).isEqualTo("/foo%2Fbar");
+        fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
   public void trailingSlashes() {
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/s/foo/bar/", "/c", "/s"))).isEqualTo("/foo/bar/");
+        fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/s/foo/bar///", "/c", "/s"))).isEqualTo("/foo/bar/");
+        fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/s/foo%2Fbar/", "/c", "/s"))).isEqualTo("/foo%2Fbar/");
+        fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/s/foo%2Fbar///", "/c", "/s"))).isEqualTo("/foo%2Fbar/");
+        fakeRequest("/c", "/s", "/foo%2Fbar///"))).isEqualTo("/foo%2Fbar/");
   }
 
   @Test
-  public void servletPathMatchesRequestPath() {
+  public void emptyPathInfo() {
     assertThat(RequestUtil.getEncodedPathInfo(
-        mockRequest("/c/s", "/c", "/s"))).isNull();
+        fakeRequest("/c", "/s", ""))).isNull();
   }
 
-  private HttpServletRequest mockRequest(String uri, String contextPath, String servletPath) {
-    HttpServletRequest req = createMock(HttpServletRequest.class);
-    expect(req.getRequestURI()).andStubReturn(uri);
-    expect(req.getContextPath()).andStubReturn(contextPath);
-    expect(req.getServletPath()).andStubReturn(servletPath);
-    replay(req);
-    mocks.add(req);
-    return req;
+  private FakeHttpServletRequest fakeRequest(String contextPath,
+      String servletPath, String pathInfo) {
+    FakeHttpServletRequest req = new FakeHttpServletRequest(
+        "gerrit.example.com", 80, contextPath, servletPath);
+    return req.setPathInfo(pathInfo);
   }
 }
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
new file mode 100644
index 0000000..3991b95
--- /dev/null
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -0,0 +1,488 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.http.testutil;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.restapi.Url;
+
+import org.apache.http.client.utils.DateUtils;
+
+import java.io.BufferedReader;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.DispatcherType;
+import javax.servlet.RequestDispatcher;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletInputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import javax.servlet.http.HttpUpgradeHandler;
+import javax.servlet.http.Part;
+
+/** Simple fake implementation of {@link HttpServletRequest}. */
+public class FakeHttpServletRequest implements HttpServletRequest {
+  public static final String SERVLET_PATH = "/b";
+
+  private final Map<String, Object> attributes;
+  private final ListMultimap<String, String> headers;
+
+  private ListMultimap<String, String> parameters;
+  private String hostName;
+  private int port;
+  private String contextPath;
+  private String servletPath;
+  private String path;
+
+  public FakeHttpServletRequest() {
+    this("gerrit.example.com", 80, "", SERVLET_PATH);
+  }
+
+  public FakeHttpServletRequest(String hostName, int port, String contextPath,
+      String servletPath) {
+    this.hostName = checkNotNull(hostName, "hostName");
+    checkArgument(port > 0);
+    this.port = port;
+    this.contextPath = checkNotNull(contextPath, "contextPath");
+    this.servletPath = checkNotNull(servletPath, "servletPath");
+    attributes = Maps.newConcurrentMap();
+    parameters = LinkedListMultimap.create();
+    headers = LinkedListMultimap.create();
+  }
+
+  @Override
+  public Object getAttribute(String name) {
+    return attributes.get(name);
+  }
+
+  @Override
+  public Enumeration<String> getAttributeNames() {
+    return Collections.enumeration(attributes.keySet());
+  }
+
+  @Override
+  public String getCharacterEncoding() {
+    return UTF_8.name();
+  }
+
+  @Override
+  public int getContentLength() {
+    return -1;
+  }
+
+  @Override
+  public String getContentType() {
+    return null;
+  }
+
+  @Override
+  public ServletInputStream getInputStream() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getLocalAddr() {
+    return "1.2.3.4";
+  }
+
+  @Override
+  public String getLocalName() {
+    return hostName;
+  }
+
+  @Override
+  public int getLocalPort() {
+    return port;
+  }
+
+  @Override
+  public Locale getLocale() {
+    return Locale.US;
+  }
+
+  @Override
+  public Enumeration<Locale> getLocales() {
+    return Collections.enumeration(Collections.singleton(Locale.US));
+  }
+
+  @Override
+  public String getParameter(String name) {
+    return Iterables.getFirst(parameters.get(name), null);
+  }
+
+  private static final Function<Collection<String>, String[]> STRING_COLLECTION_TO_ARRAY =
+      new Function<Collection<String>, String[]>() {
+        @Override
+        public String[] apply(Collection<String> values) {
+          return values.toArray(new String[0]);
+        }
+      };
+
+  @Override
+  public Map<String, String[]> getParameterMap() {
+    return Collections.unmodifiableMap(
+        Maps.transformValues(parameters.asMap(), STRING_COLLECTION_TO_ARRAY));
+  }
+
+  @Override
+  public Enumeration<String> getParameterNames() {
+    return Collections.enumeration(parameters.keySet());
+  }
+
+  @Override
+  public String[] getParameterValues(String name) {
+    return STRING_COLLECTION_TO_ARRAY.apply(parameters.get(name));
+  }
+
+  public void setQueryString(String qs) {
+    ListMultimap<String, String> params = LinkedListMultimap.create();
+    for (String entry : Splitter.on('&').split(qs)) {
+      List<String> kv = Splitter.on('=').limit(2).splitToList(entry);
+      try {
+        params.put(URLDecoder.decode(kv.get(0), UTF_8.name()),
+            kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8.name()) : "");
+      } catch (UnsupportedEncodingException e) {
+        throw new IllegalArgumentException(e);
+      }
+    }
+    parameters = params;
+  }
+
+  @Override
+  public String getProtocol() {
+    return "HTTP/1.1";
+  }
+
+  @Override
+  public BufferedReader getReader() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String getRealPath(String path) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getRemoteAddr() {
+    return "5.6.7.8";
+  }
+
+  @Override
+  public String getRemoteHost() {
+    return "remotehost";
+  }
+
+  @Override
+  public int getRemotePort() {
+    return 1234;
+  }
+
+  @Override
+  public RequestDispatcher getRequestDispatcher(String path) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getScheme() {
+    return port == 443 ? "https" : "http";
+  }
+
+  @Override
+  public String getServerName() {
+    return hostName;
+  }
+
+  @Override
+  public int getServerPort() {
+    return port;
+  }
+
+  @Override
+  public boolean isSecure() {
+    return port == 443;
+  }
+
+  @Override
+  public void removeAttribute(String name) {
+    attributes.remove(name);
+  }
+
+  @Override
+  public void setAttribute(String name, Object value) {
+    attributes.put(name, value);
+  }
+
+  @Override
+  public void setCharacterEncoding(String env) throws UnsupportedOperationException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getAuthType() {
+    return null;
+  }
+
+  @Override
+  public String getContextPath() {
+    return contextPath;
+  }
+
+  @Override
+  public Cookie[] getCookies() {
+    return new Cookie[0];
+  }
+
+  @Override
+  public long getDateHeader(String name) {
+    String v = getHeader(name);
+    return v != null ? DateUtils.parseDate(v).getTime() : 0;
+  }
+
+  @Override
+  public String getHeader(String name) {
+    return Iterables.getFirst(headers.get(name), null);
+  }
+
+  @Override
+  public Enumeration<String> getHeaderNames() {
+    return Collections.enumeration(headers.keySet());
+  }
+
+  @Override
+  public Enumeration<String> getHeaders(String name) {
+    return Collections.enumeration(headers.get(name));
+  }
+
+  @Override
+  public int getIntHeader(String name) {
+    return Integer.parseInt(getHeader(name));
+  }
+
+  @Override
+  public String getMethod() {
+    return "GET";
+  }
+
+  @Override
+  public String getPathInfo() {
+    return path;
+  }
+
+  public FakeHttpServletRequest setPathInfo(String path) {
+    this.path = path;
+    return this;
+  }
+
+  @Override
+  public String getPathTranslated() {
+    return path;
+  }
+
+  @Override
+  public String getQueryString() {
+    return paramsToString(parameters);
+  }
+
+  @Override
+  public String getRemoteUser() {
+    return null;
+  }
+
+  @Override
+  public String getRequestURI() {
+    String uri = contextPath + servletPath + path;
+    if (!parameters.isEmpty()) {
+      uri += "?" + paramsToString(parameters);
+    }
+    return uri;
+  }
+
+  @Override
+  public StringBuffer getRequestURL() {
+    return null;
+  }
+
+  @Override
+  public String getRequestedSessionId() {
+    return null;
+  }
+
+  @Override
+  public String getServletPath() {
+    return servletPath;
+  }
+
+  @Override
+  public HttpSession getSession() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public HttpSession getSession(boolean create) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Principal getUserPrincipal() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdFromCookie() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdFromURL() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public boolean isRequestedSessionIdFromUrl() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isRequestedSessionIdValid() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isUserInRole(String role) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static String paramsToString(ListMultimap<String, String> params) {
+    StringBuilder sb = new StringBuilder();
+    boolean first = true;
+    for (Map.Entry<String, String> e : params.entries()) {
+      if (!first) {
+        sb.append('&');
+      } else {
+        first = false;
+      }
+      sb.append(Url.encode(e.getKey()));
+      if (!"".equals(e.getValue())) {
+        sb.append('=').append(Url.encode(e.getValue()));
+      }
+    }
+    return sb.toString();
+  }
+
+  @Override
+  public AsyncContext getAsyncContext() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public DispatcherType getDispatcherType() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ServletContext getServletContext() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isAsyncStarted() {
+    return false;
+  }
+
+  @Override
+  public boolean isAsyncSupported() {
+    return false;
+  }
+
+  @Override
+  public AsyncContext startAsync() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public AsyncContext startAsync(ServletRequest req, ServletResponse res) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean authenticate(HttpServletResponse res) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Part getPart(String name) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Collection<Part> getParts() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void login(String username, String password) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void logout() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public long getContentLengthLong() {
+    return getContentLength();
+  }
+
+  @Override
+  public String changeSessionId() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T extends HttpUpgradeHandler> T upgrade(
+      Class<T> httpUpgradeHandlerClass) {
+    throw new UnsupportedOperationException();
+  }
+
+  public FakeHttpServletRequest addHeader(String name, String value) {
+    headers.put(name, value);
+    return this;
+  }
+}
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
new file mode 100644
index 0000000..442c474
--- /dev/null
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -0,0 +1,289 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.http.testutil;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.net.HttpHeaders;
+
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.Charset;
+import java.util.Collection;
+import java.util.Locale;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+/** Simple fake implementation of {@link HttpServletResponse}. */
+public class FakeHttpServletResponse implements HttpServletResponse {
+  private final ByteArrayOutputStream actualBody = new ByteArrayOutputStream();
+  private final ListMultimap<String, String> headers = LinkedListMultimap.create();
+
+  private int status = SC_OK;
+  private boolean committed;
+  private ServletOutputStream outputStream;
+  private PrintWriter writer;
+
+  public FakeHttpServletResponse() {
+  }
+
+  @Override
+  public synchronized void flushBuffer() throws IOException {
+    if (outputStream != null) {
+      outputStream.flush();
+    }
+    if (writer != null) {
+      writer.flush();
+    }
+  }
+
+  @Override
+  public int getBufferSize() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String getCharacterEncoding() {
+    return UTF_8.name();
+  }
+
+  @Override
+  public String getContentType() {
+    return null;
+  }
+
+  @Override
+  public Locale getLocale() {
+    return Locale.US;
+  }
+
+  @Override
+  public synchronized ServletOutputStream getOutputStream() {
+    checkState(writer == null, "getWriter() already called");
+    if (outputStream == null) {
+      outputStream = new ServletOutputStream() {
+        @Override
+        public void write(int c) throws IOException {
+          actualBody.write(c);
+        }
+
+        @Override
+        public boolean isReady() {
+          return true;
+        }
+
+        @Override
+        public void setWriteListener(WriteListener listener) {
+          throw new UnsupportedOperationException();
+        }
+      };
+    }
+    return outputStream;
+  }
+
+  @Override
+  public synchronized PrintWriter getWriter() {
+    checkState(outputStream == null, "getOutputStream() already called");
+    if (writer == null) {
+      writer = new PrintWriter(actualBody);
+    }
+    return writer;
+  }
+
+  @Override
+  public synchronized boolean isCommitted() {
+    return committed;
+  }
+
+  @Override
+  public void reset() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void resetBuffer() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setBufferSize(int sz) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setCharacterEncoding(String name) {
+    checkArgument(UTF_8.equals(Charset.forName(name)),
+        "unsupported charset: %s", name);
+  }
+
+  @Override
+  public void setContentLength(int length) {
+    setContentLengthLong(length);
+  }
+
+  @Override
+  public void setContentLengthLong(long length) {
+    headers.removeAll(HttpHeaders.CONTENT_LENGTH);
+    addHeader(HttpHeaders.CONTENT_LENGTH, Long.toString(length));
+  }
+
+  @Override
+  public void setContentType(String type) {
+    headers.removeAll(HttpHeaders.CONTENT_TYPE);
+    addHeader(HttpHeaders.CONTENT_TYPE, type);
+  }
+
+  @Override
+  public void setLocale(Locale locale) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addCookie(Cookie cookie) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addDateHeader(String name, long value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addHeader(String name, String value) {
+    headers.put(name.toLowerCase(), value);
+  }
+
+  @Override
+  public void addIntHeader(String name, int value) {
+    addHeader(name, Integer.toString(value));
+  }
+
+  @Override
+  public boolean containsHeader(String name) {
+    return headers.containsKey(name.toLowerCase());
+  }
+
+  @Override
+  public String encodeRedirectURL(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String encodeRedirectUrl(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public String encodeURL(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  @Deprecated
+  public String encodeUrl(String url) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public synchronized void sendError(int sc) {
+    status = sc;
+    committed = true;
+  }
+
+  @Override
+  public synchronized void sendError(int sc, String msg) {
+    status = sc;
+    committed = true;
+  }
+
+  @Override
+  public synchronized void sendRedirect(String loc) {
+    status = SC_FOUND;
+    setHeader(HttpHeaders.LOCATION, loc);
+    committed = true;
+  }
+
+  @Override
+  public void setDateHeader(String name, long value) {
+    setHeader(name, Long.toString(value));
+  }
+
+  @Override
+  public void setHeader(String name, String value) {
+    headers.removeAll(name.toLowerCase());
+    addHeader(name, value);
+  }
+
+  @Override
+  public void setIntHeader(String name, int value) {
+    headers.removeAll(name.toLowerCase());
+    addIntHeader(name, value);
+  }
+
+  @Override
+  public synchronized void setStatus(int sc) {
+    status = sc;
+    committed = true;
+  }
+
+  @Override
+  @Deprecated
+  public synchronized void setStatus(int sc, String msg) {
+    status = sc;
+    committed = true;
+  }
+
+  @Override
+  public synchronized int getStatus() {
+    return status;
+  }
+
+  @Override
+  public String getHeader(String name) {
+    return Iterables.getFirst(
+        headers.get(checkNotNull(name.toLowerCase())), null);
+  }
+
+  @Override
+  public Collection<String> getHeaderNames() {
+    return headers.keySet();
+  }
+
+  @Override
+  public Collection<String> getHeaders(String name) {
+    return headers.get(checkNotNull(name.toLowerCase()));
+  }
+
+  public byte[] getActualBody() {
+    return actualBody.toByteArray();
+  }
+
+  public String getActualBodyString() {
+    return RawParseUtils.decode(getActualBody());
+  }
+}
diff --git a/lib/BUCK b/lib/BUCK
index a92f910..c635bbb 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -184,6 +184,16 @@
   license = 'protobuf',
 )
 
+# Test-only dependencies below.
+
+maven_jar(
+  name = 'jimfs',
+  id = 'com.google.jimfs:jimfs:1.0',
+  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
+  license = 'DO_NOT_DISTRIBUTE',
+  deps = [':guava'],
+)
+
 maven_jar(
   name = 'junit',
   id = 'junit:junit:4.11',