diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index c1eb34ac61..1b7130a57d 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -75,6 +75,7 @@ add_executable(nxt_end2end_tests
     ${END2END_TESTS_DIR}/BasicTests.cpp
     ${END2END_TESTS_DIR}/BufferTests.cpp
     ${END2END_TESTS_DIR}/BlendStateTests.cpp
+    ${END2END_TESTS_DIR}/ComputeCopyStorageBufferTests.cpp
     ${END2END_TESTS_DIR}/CopyTests.cpp
     ${END2END_TESTS_DIR}/DrawElementsTests.cpp
     ${END2END_TESTS_DIR}/DepthStencilStateTests.cpp
diff --git a/src/tests/end2end/ComputeCopyStorageBufferTests.cpp b/src/tests/end2end/ComputeCopyStorageBufferTests.cpp
new file mode 100644
index 0000000000..7be1e7d569
--- /dev/null
+++ b/src/tests/end2end/ComputeCopyStorageBufferTests.cpp
@@ -0,0 +1,170 @@
+// Copyright 2018 The NXT Authors
+//
+// 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.
+
+#include "tests/NXTTest.h"
+
+#include "utils/NXTHelpers.h"
+
+#include <array>
+
+class ComputeCopyStorageBufferTests : public NXTTest {
+  public:
+    static constexpr int kInstances = 4;
+    static constexpr int kUintsPerInstance = 4;
+    static constexpr int kNumUints = kInstances * kUintsPerInstance;
+
+    void BasicTest(const char* shader);
+};
+
+void ComputeCopyStorageBufferTests::BasicTest(const char* shader) {
+    auto bgl = utils::MakeBindGroupLayout(
+        device, {
+                    {0, nxt::ShaderStageBit::Compute, nxt::BindingType::StorageBuffer},
+                    {1, nxt::ShaderStageBit::Compute, nxt::BindingType::StorageBuffer},
+                });
+
+    // Set up shader and pipeline
+    auto module = utils::CreateShaderModule(device, nxt::ShaderStage::Compute, shader);
+    auto pl = utils::MakeBasicPipelineLayout(device, &bgl);
+    auto pipeline = device.CreateComputePipelineBuilder()
+                        .SetLayout(pl)
+                        .SetStage(nxt::ShaderStage::Compute, module, "main")
+                        .GetResult();
+
+    // Set up src storage buffer
+    auto src =
+        device.CreateBufferBuilder()
+            .SetSize(kNumUints * sizeof(uint32_t))
+            .SetAllowedUsage(nxt::BufferUsageBit::Storage | nxt::BufferUsageBit::TransferSrc |
+                             nxt::BufferUsageBit::TransferDst)
+            .SetInitialUsage(nxt::BufferUsageBit::TransferDst)
+            .GetResult();
+    std::array<uint32_t, kNumUints> expected;
+    for (uint32_t i = 0; i < kNumUints; ++i) {
+        expected[i] = (i + 1u) * 0x11111111u;
+    }
+    src.SetSubData(0, sizeof(expected), reinterpret_cast<const uint8_t*>(expected.data()));
+    EXPECT_BUFFER_U32_RANGE_EQ(expected.data(), src, 0, kNumUints);
+    auto srcView =
+        src.CreateBufferViewBuilder().SetExtent(0, kNumUints * sizeof(uint32_t)).GetResult();
+
+    // Set up dst storage buffer
+    auto dst =
+        device.CreateBufferBuilder()
+            .SetSize(kNumUints * sizeof(uint32_t))
+            .SetAllowedUsage(nxt::BufferUsageBit::Storage | nxt::BufferUsageBit::TransferSrc |
+                             nxt::BufferUsageBit::TransferDst)
+            .SetInitialUsage(nxt::BufferUsageBit::TransferDst)
+            .GetResult();
+    std::array<uint32_t, kNumUints> zero{};
+    dst.SetSubData(0, sizeof(zero), reinterpret_cast<const uint8_t*>(zero.data()));
+    auto dstView =
+        dst.CreateBufferViewBuilder().SetExtent(0, kNumUints * sizeof(uint32_t)).GetResult();
+
+    // Set up bind group and issue dispatch
+    auto bindGroup = device.CreateBindGroupBuilder()
+                         .SetLayout(bgl)
+                         .SetUsage(nxt::BindGroupUsage::Frozen)
+                         .SetBufferViews(0, 1, &srcView)
+                         .SetBufferViews(1, 1, &dstView)
+                         .GetResult();
+    auto commands = device.CreateCommandBufferBuilder()
+                        .TransitionBufferUsage(src, nxt::BufferUsageBit::Storage)
+                        .TransitionBufferUsage(dst, nxt::BufferUsageBit::Storage)
+                        .BeginComputePass()
+                        .SetComputePipeline(pipeline)
+                        .SetBindGroup(0, bindGroup)
+                        .Dispatch(kInstances, 1, 1)
+                        .EndComputePass()
+                        .GetResult();
+
+    queue.Submit(1, &commands);
+
+    EXPECT_BUFFER_U32_RANGE_EQ(expected.data(), dst, 0, kNumUints);
+}
+
+// Test that a trivial compute-shader memcpy implementation works.
+TEST_P(ComputeCopyStorageBufferTests, BasicTest) {
+    BasicTest(R"(
+        #version 450
+        #define kInstances 4
+        layout(std140, set = 0, binding = 0) buffer Src { uvec4 s[kInstances]; } src;
+        layout(std140, set = 0, binding = 1) buffer Dst { uvec4 s[kInstances]; } dst;
+        void main() {
+            uint index = gl_GlobalInvocationID.x;
+            if (index >= kInstances) { return; }
+            dst.s[index] = src.s[index];
+        })");
+}
+
+// Test that a slightly-less-trivial compute-shader memcpy implementation works.
+TEST_P(ComputeCopyStorageBufferTests, StructTest) {
+    BasicTest(R"(
+        #version 450
+        #define kInstances 4
+        struct S {
+            uvec2 a, b;  // kUintsPerInstance = 4
+        };
+        layout(std140, set = 0, binding = 0) buffer Src { S s[kInstances]; } src;
+        layout(std140, set = 0, binding = 1) buffer Dst { S s[kInstances]; } dst;
+        void main() {
+            uint index = gl_GlobalInvocationID.x;
+            if (index >= kInstances) { return; }
+            dst.s[index] = src.s[index];
+        })");
+}
+
+// Test with a sized array SSBO.
+TEST_P(ComputeCopyStorageBufferTests, DISABLED_SizedArray) {
+    // TODO(kainino@chromium.org): Fails on OpenGL (only copies one instance, not 4).
+    // TODO(kainino@chromium.org): Fails on Vulkan (program hangs).
+    BasicTest(R"(
+        #version 450
+        #define kInstances 4
+        struct S {
+            uvec2 a, b;  // kUintsPerInstance = 4
+        };
+        layout(std140, set = 0, binding = 0) buffer Src { S s; } src[kInstances];
+        layout(std140, set = 0, binding = 1) buffer Dst { S s; } dst[kInstances];
+        void main() {
+            uint index = gl_GlobalInvocationID.x;
+            if (index >= kInstances) { return; }
+            dst[index].s = src[index].s;
+        })");
+}
+
+// Test with an unsized array SSBO.
+TEST_P(ComputeCopyStorageBufferTests, DISABLED_UnsizedArray) {
+    // TODO(kainino@chromium.org): On OpenGL, compilation fails but the test passes. Why?
+    // TODO(kainino@chromium.org): On Metal, compilation fails and test crashes.
+    BasicTest(R"(
+        #version 450
+        #define kInstances 4
+        struct S {
+            uvec2 a, b;  // kUintsPerInstance = 4
+        };
+        layout(std140, set = 0, binding = 0) buffer Src { S s; } src[];
+        layout(std140, set = 0, binding = 1) buffer Dst { S s; } dst[];
+        void main() {
+            uint index = gl_GlobalInvocationID.x;
+            if (index >= kInstances) { return; }
+            dst[index].s = src[index].s;
+        })");
+}
+
+NXT_INSTANTIATE_TEST(ComputeCopyStorageBufferTests,
+                     D3D12Backend,
+                     MetalBackend,
+                     OpenGLBackend,
+                     VulkanBackend)