diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/TransferApiImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/TransferApiImpl.java
index 538caddde..f1d227425 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/TransferApiImpl.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/TransferApiImpl.java
@@ -20,11 +20,15 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.concurrent.atomic.AtomicLong;
 
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
 import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
 import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
 import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
 
 public class TransferApiImpl {
+	public static final Logger LOGGER = LogManager.getLogger("fabric-transfer-api-v1");
 	public static final AtomicLong version = new AtomicLong();
 	@SuppressWarnings("rawtypes")
 	public static final Storage EMPTY_STORAGE = new Storage() {
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
index cacd3d4c5..27890d0e7 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
@@ -20,13 +20,11 @@ import org.spongepowered.asm.mixin.Mixin;
 import org.spongepowered.asm.mixin.injection.At;
 import org.spongepowered.asm.mixin.injection.Inject;
 import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
-import org.spongepowered.asm.mixin.injection.callback.LocalCapture;
 
 import net.minecraft.block.DispenserBlock;
 import net.minecraft.block.DropperBlock;
 import net.minecraft.block.entity.DispenserBlockEntity;
 import net.minecraft.server.world.ServerWorld;
-import net.minecraft.util.math.BlockPointerImpl;
 import net.minecraft.util.math.BlockPos;
 import net.minecraft.util.math.Direction;
 
@@ -35,37 +33,47 @@ import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
 import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
 import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
 import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
+import net.fabricmc.fabric.impl.transfer.TransferApiImpl;
 
 /**
  * Allows droppers to insert into ItemVariant storages.
- *
- * <p>Maintainer note: it's important that we inject BEFORE the getStack() call,
- * as the returned stack can be mutated by the StorageUtil.move() call in the injected callback.
  */
 @Mixin(DropperBlock.class)
 public class DropperBlockMixin {
 	@Inject(
 			at = @At(
 					value = "INVOKE",
-					target = "Lnet/minecraft/block/entity/DispenserBlockEntity;getStack(I)Lnet/minecraft/item/ItemStack;"
+					target = "Lnet/minecraft/block/dispenser/DispenserBehavior;dispense(Lnet/minecraft/util/math/BlockPointer;Lnet/minecraft/item/ItemStack;)Lnet/minecraft/item/ItemStack;"
 			),
 			method = "dispense",
-			locals = LocalCapture.CAPTURE_FAILHARD,
 			cancellable = true,
 			allow = 1
 	)
-	public void hookDispense(ServerWorld world, BlockPos pos, CallbackInfo ci, BlockPointerImpl blockPointerImpl, DispenserBlockEntity dispenser, int slot) {
-		if (dispenser.getStack(slot).isEmpty()) return;
+	public void hookDispense(ServerWorld world, BlockPos pos, CallbackInfo ci) {
+		DispenserBlockEntity dispenser = (DispenserBlockEntity) world.getBlockEntity(pos);
+		Direction direction = dispenser.getCachedState().get(DispenserBlock.FACING);
 
-		Direction direction = world.getBlockState(pos).get(DispenserBlock.FACING);
 		Storage<ItemVariant> target = ItemStorage.SIDED.find(world, pos.offset(direction), direction.getOpposite());
 
 		if (target != null) {
-			Storage<ItemVariant> source = InventoryStorage.of(dispenser, null).getSlots().get(slot);
+			// Always cancel if a storage is available.
+			ci.cancel();
 
-			if (StorageUtil.move(source, target, k -> true, 1, null) == 1) {
-				ci.cancel();
+			// We pick a non empty slot. It's not necessarily the same as the one vanilla picked, but that doesn't matter.
+			int slot = dispenser.chooseNonEmptySlot();
+
+			if (slot == -1) {
+				TransferApiImpl.LOGGER.warn("Skipping dropper transfer because the empty slot is unexpectedly -1.");
+				return;
 			}
+
+			StorageUtil.move(
+					InventoryStorage.of(dispenser, null).getSlots().get(slot),
+					target,
+					k -> true,
+					1,
+					null
+			);
 		}
 	}
 }
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
index c48a3cc75..cc0d2cb88 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
@@ -16,15 +16,14 @@
 
 package net.fabricmc.fabric.mixin.transfer;
 
-import org.jetbrains.annotations.Nullable;
 import org.spongepowered.asm.mixin.Mixin;
 import org.spongepowered.asm.mixin.injection.At;
 import org.spongepowered.asm.mixin.injection.Inject;
 import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+import org.spongepowered.asm.mixin.injection.callback.LocalCapture;
 
 import net.minecraft.block.BlockState;
 import net.minecraft.block.HopperBlock;
-import net.minecraft.block.entity.BlockEntity;
 import net.minecraft.block.entity.Hopper;
 import net.minecraft.block.entity.HopperBlockEntity;
 import net.minecraft.inventory.Inventory;
@@ -44,49 +43,61 @@ import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
 @Mixin(HopperBlockEntity.class)
 public class HopperBlockEntityMixin {
 	@Inject(
-			at = @At("HEAD"),
+			at = @At(
+					value = "INVOKE_ASSIGN",
+					target = "Lnet/minecraft/block/entity/HopperBlockEntity;getOutputInventory(Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/block/BlockState;)Lnet/minecraft/inventory/Inventory;"
+			),
 			method = "insert(Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/block/BlockState;Lnet/minecraft/inventory/Inventory;)Z",
+			locals = LocalCapture.CAPTURE_FAILHARD,
 			cancellable = true
 	)
-	private static void hookInsert(World world, BlockPos pos, BlockState state, Inventory inventory, CallbackInfoReturnable<Boolean> cir) {
+	private static void hookInsert(World world, BlockPos pos, BlockState state, Inventory inventory, CallbackInfoReturnable<Boolean> cir, Inventory targetInventory) {
+		// Let vanilla handle the transfer if it found an inventory.
+		if (targetInventory != null) return;
+
+		// Otherwise inject our transfer logic.
 		Direction direction = state.get(HopperBlock.FACING);
 		BlockPos targetPos = pos.offset(direction);
-		BlockEntity targetBe = world.getBlockEntity(targetPos);
-		Storage<ItemVariant> target = ItemStorage.SIDED.find(world, targetPos, null, targetBe, direction.getOpposite());
+		Storage<ItemVariant> target = ItemStorage.SIDED.find(world, targetPos, direction.getOpposite());
 
 		if (target != null) {
-			cir.setReturnValue(doTransfer(InventoryStorage.of(inventory, direction), target, inventory, targetBe));
+			long moved = StorageUtil.move(
+					InventoryStorage.of(inventory, direction),
+					target,
+					iv -> true,
+					1,
+					null
+			);
+			cir.setReturnValue(moved == 1);
 		}
 	}
 
 	@Inject(
-			at = @At("HEAD"),
+			at = @At(
+					value = "INVOKE_ASSIGN",
+					target = "Lnet/minecraft/block/entity/HopperBlockEntity;getInputInventory(Lnet/minecraft/world/World;Lnet/minecraft/block/entity/Hopper;)Lnet/minecraft/inventory/Inventory;"
+			),
 			method = "extract(Lnet/minecraft/world/World;Lnet/minecraft/block/entity/Hopper;)Z",
+			locals = LocalCapture.CAPTURE_FAILHARD,
 			cancellable = true
 	)
-	private static void hookExtract(World world, Hopper hopper, CallbackInfoReturnable<Boolean> cir) {
+	private static void hookExtract(World world, Hopper hopper, CallbackInfoReturnable<Boolean> cir, Inventory inputInventory) {
+		// Let vanilla handle the transfer if it found an inventory.
+		if (inputInventory != null) return;
+
+		// Otherwise inject our transfer logic.
 		BlockPos sourcePos = new BlockPos(hopper.getHopperX(), hopper.getHopperY() + 1.0D, hopper.getHopperZ());
-		BlockEntity sourceBe = world.getBlockEntity(sourcePos);
-		Storage<ItemVariant> source = ItemStorage.SIDED.find(world, sourcePos, null, sourceBe, Direction.DOWN);
+		Storage<ItemVariant> source = ItemStorage.SIDED.find(world, sourcePos, Direction.DOWN);
 
 		if (source != null) {
-			cir.setReturnValue(doTransfer(source, InventoryStorage.of(hopper, Direction.UP), sourceBe, hopper));
-		}
-	}
-
-	private static boolean doTransfer(Storage<ItemVariant> from, Storage<ItemVariant> to, @Nullable Object invFrom, @Nullable Object invTo) {
-		if (invFrom instanceof HopperBlockEntityAccessor hopperFrom && invTo instanceof HopperBlockEntityAccessor hopperTo) {
-			// Hoppers have some special interactions (see HopperBlockEntity#transfer)
-			boolean wasEmpty = hopperTo.isEmpty();
-			boolean moved = StorageUtil.move(from, to, k -> true, 1, null) == 1;
-
-			if (moved && wasEmpty && hopperTo.fabric_getLastTickTime() >= hopperFrom.fabric_getLastTickTime()) {
-				hopperTo.fabric_callSetCooldown(7);
-			}
-
-			return moved;
-		} else {
-			return StorageUtil.move(from, to, k -> true, 1, null) == 1;
+			long moved = StorageUtil.move(
+					source,
+					InventoryStorage.of(hopper, Direction.UP),
+					iv -> true,
+					1,
+					null
+			);
+			cir.setReturnValue(moved == 1);
 		}
 	}
 }
diff --git a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
index becd80534..f084d4d06 100644
--- a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
+++ b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
@@ -7,7 +7,6 @@
     "DoubleInventoryAccessor",
     "DropperBlockMixin",
     "FluidMixin",
-    "HopperBlockEntityAccessor",
     "HopperBlockEntityMixin",
     "ItemMixin"
   ]
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/CreativeFluidStorage.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/CreativeStorage.java
similarity index 55%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/CreativeFluidStorage.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/CreativeStorage.java
index 35bacc7ec..f2121ae72 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/CreativeFluidStorage.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/CreativeStorage.java
@@ -14,37 +14,38 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
-
-import java.util.Iterator;
+package net.fabricmc.fabric.test.transfer.ingame;
 
 import net.minecraft.fluid.Fluids;
+import net.minecraft.item.Items;
 
 import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.TransferVariant;
 import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
-import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.ExtractionOnlyStorage;
-import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleViewIterator;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
 import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
 
-public class CreativeFluidStorage implements ExtractionOnlyStorage<FluidVariant>, StorageView<FluidVariant> {
-	public static final CreativeFluidStorage WATER = new CreativeFluidStorage(FluidVariant.of(Fluids.WATER));
-	public static final CreativeFluidStorage LAVA = new CreativeFluidStorage(FluidVariant.of(Fluids.LAVA));
+public class CreativeStorage<T extends TransferVariant<?>> implements ExtractionOnlyStorage<T>, SingleSlotStorage<T> {
+	public static final CreativeStorage<FluidVariant> WATER = new CreativeStorage<>(FluidVariant.of(Fluids.WATER));
+	public static final CreativeStorage<FluidVariant> LAVA = new CreativeStorage<>(FluidVariant.of(Fluids.LAVA));
+	public static final CreativeStorage<ItemVariant> DIAMONDS = new CreativeStorage<>(ItemVariant.of(Items.DIAMOND));
 
-	private final FluidVariant infiniteFluid;
+	private final T infiniteResource;
 
-	private CreativeFluidStorage(FluidVariant infiniteFluid) {
-		this.infiniteFluid = infiniteFluid;
+	private CreativeStorage(T infiniteResource) {
+		this.infiniteResource = infiniteResource;
 	}
 
 	@Override
 	public boolean isResourceBlank() {
-		return infiniteFluid.isBlank();
+		return infiniteResource.isBlank();
 	}
 
 	@Override
-	public FluidVariant getResource() {
-		return infiniteFluid;
+	public T getResource() {
+		return infiniteResource;
 	}
 
 	@Override
@@ -58,21 +59,16 @@ public class CreativeFluidStorage implements ExtractionOnlyStorage<FluidVariant>
 	}
 
 	@Override
-	public long extract(FluidVariant resource, long maxAmount, TransactionContext transaction) {
+	public long extract(T resource, long maxAmount, TransactionContext transaction) {
 		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
 
-		if (resource.equals(infiniteFluid)) {
+		if (resource.equals(infiniteResource)) {
 			return maxAmount;
 		} else {
 			return 0;
 		}
 	}
 
-	@Override
-	public Iterator<StorageView<FluidVariant>> iterator(TransactionContext transaction) {
-		return SingleViewIterator.create(this, transaction);
-	}
-
 	@Override
 	public long getVersion() {
 		return 0;
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ExtractStickItem.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/ExtractStickItem.java
similarity index 97%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ExtractStickItem.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/ExtractStickItem.java
index d284890f5..140d7baef 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ExtractStickItem.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/ExtractStickItem.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.ingame;
 
 import net.minecraft.item.Item;
 import net.minecraft.item.ItemGroup;
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlock.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlock.java
similarity index 97%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlock.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlock.java
index 4bfc0389c..ed3f186c8 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlock.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlock.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.ingame;
 
 import org.jetbrains.annotations.Nullable;
 
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlockEntity.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlockEntity.java
similarity index 93%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlockEntity.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlockEntity.java
index 5bc537bb6..2cc6411b4 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidChuteBlockEntity.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/FluidChuteBlockEntity.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.ingame;
 
 import net.minecraft.block.BlockState;
 import net.minecraft.block.entity.BlockEntity;
@@ -31,7 +31,7 @@ public class FluidChuteBlockEntity extends BlockEntity {
 	private int tickCounter = 0;
 
 	public FluidChuteBlockEntity(BlockPos pos, BlockState state) {
-		super(FluidTransferTest.FLUID_CHUTE_TYPE, pos, state);
+		super(TransferTestInitializer.FLUID_CHUTE_TYPE, pos, state);
 	}
 
 	@SuppressWarnings("ConstantConditions")
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java
new file mode 100644
index 000000000..f608e11ab
--- /dev/null
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TransferTestInitializer.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * 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 net.fabricmc.fabric.test.transfer.ingame;
+
+import net.minecraft.block.AbstractBlock;
+import net.minecraft.block.Block;
+import net.minecraft.block.Blocks;
+import net.minecraft.block.Material;
+import net.minecraft.block.entity.BlockEntityType;
+import net.minecraft.item.BlockItem;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemGroup;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.registry.Registry;
+
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
+
+public class TransferTestInitializer implements ModInitializer {
+	public static final String MOD_ID = "fabric-transfer-api-v1-testmod";
+
+	private static final Block INFINITE_WATER_SOURCE = new Block(AbstractBlock.Settings.of(Material.METAL));
+	private static final Block INFINITE_LAVA_SOURCE = new Block(AbstractBlock.Settings.of(Material.METAL));
+	private static final Block FLUID_CHUTE = new FluidChuteBlock();
+	private static final Item EXTRACT_STICK = new ExtractStickItem();
+	public static BlockEntityType<FluidChuteBlockEntity> FLUID_CHUTE_TYPE;
+
+	@Override
+	public void onInitialize() {
+		registerBlock(INFINITE_WATER_SOURCE, "infinite_water_source");
+		registerBlock(INFINITE_LAVA_SOURCE, "infinite_lava_source");
+		registerBlock(FLUID_CHUTE, "fluid_chute");
+		Registry.register(Registry.ITEM, new Identifier(MOD_ID, "extract_stick"), EXTRACT_STICK);
+
+		FLUID_CHUTE_TYPE = FabricBlockEntityTypeBuilder.create(FluidChuteBlockEntity::new, FLUID_CHUTE).build();
+		Registry.register(Registry.BLOCK_ENTITY_TYPE, new Identifier(MOD_ID, "fluid_chute"), FLUID_CHUTE_TYPE);
+
+		FluidStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeStorage.WATER, INFINITE_WATER_SOURCE);
+		FluidStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeStorage.LAVA, INFINITE_LAVA_SOURCE);
+
+		// Obsidian is now a trash can :-P
+		ItemStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> TrashingStorage.ITEM, Blocks.OBSIDIAN);
+		// And diamond ore blocks are an infinite source of diamonds! Yay!
+		ItemStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeStorage.DIAMONDS, Blocks.DIAMOND_ORE);
+	}
+
+	private static void registerBlock(Block block, String name) {
+		Identifier id = new Identifier(MOD_ID, name);
+		Registry.register(Registry.BLOCK, id, block);
+		Registry.register(Registry.ITEM, id, new BlockItem(block, new Item.Settings().group(ItemGroup.MISC)));
+	}
+}
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/TrashingStorage.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TrashingStorage.java
similarity index 97%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/TrashingStorage.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TrashingStorage.java
index 24387fa48..30716eecc 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/TrashingStorage.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/ingame/TrashingStorage.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.ingame;
 
 import java.util.Collections;
 import java.util.Iterator;
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidItemTests.java
similarity index 99%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidItemTests.java
index 55687ea3b..50380f3a9 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidItemTests.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.unittests;
 
 import static net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants.BOTTLE;
 import static net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants.BUCKET;
@@ -43,7 +43,7 @@ import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
 import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
 
-public class FluidItemTests {
+class FluidItemTests {
 	public static void run() {
 		testFluidItemApi();
 		testWaterPotion();
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidTests.java
similarity index 59%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidTests.java
index d7c67def8..a25b7e396 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/FluidTests.java
@@ -14,67 +14,21 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.unittests;
 
 import static net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants.BUCKET;
 
-import net.minecraft.block.AbstractBlock;
-import net.minecraft.block.Block;
-import net.minecraft.block.Blocks;
-import net.minecraft.block.Material;
-import net.minecraft.block.entity.BlockEntityType;
 import net.minecraft.fluid.Fluids;
-import net.minecraft.item.BlockItem;
-import net.minecraft.item.Item;
-import net.minecraft.item.ItemGroup;
 import net.minecraft.nbt.NbtCompound;
-import net.minecraft.util.Identifier;
-import net.minecraft.util.registry.Registry;
 
-import net.fabricmc.api.ModInitializer;
-import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
 import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
-import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
-import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleVariantStorage;
 import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 
-public class FluidTransferTest implements ModInitializer {
-	public static final String MOD_ID = "fabric-transfer-api-v1-testmod";
-
-	private static final Block INFINITE_WATER_SOURCE = new Block(AbstractBlock.Settings.of(Material.METAL));
-	private static final Block INFINITE_LAVA_SOURCE = new Block(AbstractBlock.Settings.of(Material.METAL));
-	private static final Block FLUID_CHUTE = new FluidChuteBlock();
-	private static final Item EXTRACT_STICK = new ExtractStickItem();
-	public static BlockEntityType<FluidChuteBlockEntity> FLUID_CHUTE_TYPE;
-
-	@Override
-	public void onInitialize() {
-		registerBlock(INFINITE_WATER_SOURCE, "infinite_water_source");
-		registerBlock(INFINITE_LAVA_SOURCE, "infinite_lava_source");
-		registerBlock(FLUID_CHUTE, "fluid_chute");
-		Registry.register(Registry.ITEM, new Identifier(MOD_ID, "extract_stick"), EXTRACT_STICK);
-
-		FLUID_CHUTE_TYPE = FabricBlockEntityTypeBuilder.create(FluidChuteBlockEntity::new, FLUID_CHUTE).build();
-		Registry.register(Registry.BLOCK_ENTITY_TYPE, new Identifier(MOD_ID, "fluid_chute"), FLUID_CHUTE_TYPE);
-
-		FluidStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeFluidStorage.WATER, INFINITE_WATER_SOURCE);
-		FluidStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> CreativeFluidStorage.LAVA, INFINITE_LAVA_SOURCE);
-
-		// Obsidian is now a trash can :-P
-		ItemStorage.SIDED.registerForBlocks((world, pos, state, be, direction) -> TrashingStorage.ITEM, Blocks.OBSIDIAN);
-
+class FluidTests {
+	public static void run() {
 		testFluidStorage();
-		testTransactionExceptions();
-		ItemTests.run();
-		FluidItemTests.run();
-	}
-
-	private static void registerBlock(Block block, String name) {
-		Identifier id = new Identifier(MOD_ID, name);
-		Registry.register(Registry.BLOCK, id, block);
-		Registry.register(Registry.ITEM, id, new BlockItem(block, new Item.Settings().group(ItemGroup.MISC)));
 	}
 
 	private static final FluidVariant TAGGED_WATER, TAGGED_WATER_2, WATER, LAVA;
@@ -195,61 +149,4 @@ public class FluidTransferTest implements ModInitializer {
 			}
 		}
 	}
-
-	private static int callbacksInvoked = 0;
-
-	/**
-	 * Make sure that transaction global state stays valid in case of exceptions.
-	 */
-	private static void testTransactionExceptions() {
-		// Test exception inside the try.
-		ensureException(() -> {
-			try (Transaction tx = Transaction.openOuter()) {
-				tx.addCloseCallback((t, result) -> {
-					callbacksInvoked++; throw new RuntimeException("Close.");
-				});
-				throw new RuntimeException("Inside try.");
-			}
-		}, "Exception should have propagated through the transaction.");
-		if (callbacksInvoked != 1) throw new AssertionError("Callback should have been invoked.");
-
-		// Test exception inside the close.
-		callbacksInvoked = 0;
-		ensureException(() -> {
-			try (Transaction tx = Transaction.openOuter()) {
-				tx.addCloseCallback((t, result) -> {
-					callbacksInvoked++; throw new RuntimeException("Close 1.");
-				});
-				tx.addCloseCallback((t, result) -> {
-					callbacksInvoked++; throw new RuntimeException("Close 2.");
-				});
-				tx.addOuterCloseCallback(result -> {
-					callbacksInvoked++; throw new RuntimeException("Outer close 1.");
-				});
-				tx.addOuterCloseCallback(result -> {
-					callbacksInvoked++; throw new RuntimeException("Outer close 2.");
-				});
-			}
-		}, "Exceptions in close callbacks should be propagated through the transaction.");
-		if (callbacksInvoked != 4) throw new AssertionError("All 4 callbacks should have been invoked, only so many were: " + callbacksInvoked);
-
-		// Test that transaction state is still OK after these exceptions.
-		try (Transaction tx = Transaction.openOuter()) {
-			tx.commit();
-		}
-	}
-
-	private static void ensureException(Runnable runnable, String message) {
-		boolean failed = false;
-
-		try {
-			runnable.run();
-		} catch (Throwable t) {
-			failed = true;
-		}
-
-		if (!failed) {
-			throw new AssertionError(message);
-		}
-	}
 }
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/ItemTests.java
similarity index 98%
rename from fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/ItemTests.java
index b5d817acc..191ccea66 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/ItemTests.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.test.transfer.fluid;
+package net.fabricmc.fabric.test.transfer.unittests;
 
 import java.util.stream.IntStream;
 
@@ -38,7 +38,7 @@ import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 /**
  * Tests for the item transfer APIs.
  */
-public class ItemTests {
+class ItemTests {
 	public static void run() {
 		testInventoryWrappers();
 		testLimitedStackCountInventory();
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/TransactionExceptionsTests.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/TransactionExceptionsTests.java
new file mode 100644
index 000000000..3a7ebc801
--- /dev/null
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/TransactionExceptionsTests.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * 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 net.fabricmc.fabric.test.transfer.unittests;
+
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
+
+class TransactionExceptionsTests {
+	public static void run() {
+		testTransactionExceptions();
+	}
+
+	private static int callbacksInvoked = 0;
+
+	/**
+	 * Make sure that transaction global state stays valid in case of exceptions.
+	 */
+	private static void testTransactionExceptions() {
+		// Test exception inside the try.
+		ensureException(() -> {
+			try (Transaction tx = Transaction.openOuter()) {
+				tx.addCloseCallback((t, result) -> {
+					callbacksInvoked++; throw new RuntimeException("Close.");
+				});
+				throw new RuntimeException("Inside try.");
+			}
+		}, "Exception should have propagated through the transaction.");
+		if (callbacksInvoked != 1) throw new AssertionError("Callback should have been invoked.");
+
+		// Test exception inside the close.
+		callbacksInvoked = 0;
+		ensureException(() -> {
+			try (Transaction tx = Transaction.openOuter()) {
+				tx.addCloseCallback((t, result) -> {
+					callbacksInvoked++; throw new RuntimeException("Close 1.");
+				});
+				tx.addCloseCallback((t, result) -> {
+					callbacksInvoked++; throw new RuntimeException("Close 2.");
+				});
+				tx.addOuterCloseCallback(result -> {
+					callbacksInvoked++; throw new RuntimeException("Outer close 1.");
+				});
+				tx.addOuterCloseCallback(result -> {
+					callbacksInvoked++; throw new RuntimeException("Outer close 2.");
+				});
+			}
+		}, "Exceptions in close callbacks should be propagated through the transaction.");
+		if (callbacksInvoked != 4) throw new AssertionError("All 4 callbacks should have been invoked, only so many were: " + callbacksInvoked);
+
+		// Test that transaction state is still OK after these exceptions.
+		try (Transaction tx = Transaction.openOuter()) {
+			tx.commit();
+		}
+	}
+
+	private static void ensureException(Runnable runnable, String message) {
+		boolean failed = false;
+
+		try {
+			runnable.run();
+		} catch (Throwable t) {
+			failed = true;
+		}
+
+		if (!failed) {
+			throw new AssertionError(message);
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/UnitTestsInitializer.java
similarity index 51%
rename from fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java
rename to fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/UnitTestsInitializer.java
index a4784fbb8..7b7b51e3b 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/unittests/UnitTestsInitializer.java
@@ -14,23 +14,19 @@
  * limitations under the License.
  */
 
-package net.fabricmc.fabric.mixin.transfer;
+package net.fabricmc.fabric.test.transfer.unittests;
 
-import org.spongepowered.asm.mixin.Mixin;
-import org.spongepowered.asm.mixin.gen.Accessor;
-import org.spongepowered.asm.mixin.gen.Invoker;
+import org.apache.logging.log4j.LogManager;
 
-import net.minecraft.block.entity.HopperBlockEntity;
-import net.minecraft.inventory.Inventory;
+import net.fabricmc.api.ModInitializer;
 
-/**
- * Hopper accessors, for use in {@link HopperBlockEntityMixin}.
- */
-@Mixin(HopperBlockEntity.class)
-public interface HopperBlockEntityAccessor extends Inventory {
-	@Invoker("setCooldown")
-	void fabric_callSetCooldown(int cooldown);
-
-	@Accessor("lastTickTime")
-	long fabric_getLastTickTime();
+public class UnitTestsInitializer implements ModInitializer {
+	@Override
+	public void onInitialize() {
+		TransactionExceptionsTests.run();
+		FluidTests.run();
+		ItemTests.run();
+		FluidItemTests.run();
+		LogManager.getLogger("fabric-transfer-api-v1 testmod").info("Transfer API unit tests successful.");
+	}
 }
diff --git a/fabric-transfer-api-v1/src/testmod/resources/fabric.mod.json b/fabric-transfer-api-v1/src/testmod/resources/fabric.mod.json
index a2441ca41..aa6036370 100644
--- a/fabric-transfer-api-v1/src/testmod/resources/fabric.mod.json
+++ b/fabric-transfer-api-v1/src/testmod/resources/fabric.mod.json
@@ -10,7 +10,8 @@
   },
   "entrypoints": {
     "main": [
-      "net.fabricmc.fabric.test.transfer.fluid.FluidTransferTest"
+      "net.fabricmc.fabric.test.transfer.ingame.TransferTestInitializer",
+      "net.fabricmc.fabric.test.transfer.unittests.UnitTestsInitializer"
     ]
   }
 }