Allow steps to declare more than one result

Change-Id: Ifd2a19f8ff771d464737bb27e22cf05a43cc2a9f
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/103020
Reviewed-by: Johnni Winther <johnniwinther@google.com>
diff --git a/pkg/modular_test/lib/src/io_pipeline.dart b/pkg/modular_test/lib/src/io_pipeline.dart
index 6ce6d42..ed77f2d 100644
--- a/pkg/modular_test/lib/src/io_pipeline.dart
+++ b/pkg/modular_test/lib/src/io_pipeline.dart
@@ -53,40 +53,47 @@
   @override
   Future<void> runStep(IOModularStep step, Module module,
       Map<Module, Set<DataId>> visibleData) async {
-    var folder =
-        await Directory.systemTemp.createTemp('modular_test_${step.resultId}-');
-    _tmpFolders[step.resultId] ??= (await Directory.systemTemp
-            .createTemp('modular_test_${step.resultId}_res-'))
-        .uri;
+    // Since data ids are unique throughout the pipeline, we use the first
+    // result data id as a hint for the name of the temporary folder of a step.
+    var stepFolder;
+    for (var dataId in step.resultData) {
+      stepFolder ??=
+          await Directory.systemTemp.createTemp('modular_test_${dataId}-');
+      _tmpFolders[dataId] ??=
+          (await Directory.systemTemp.createTemp('modular_test_${dataId}_res-'))
+              .uri;
+    }
     for (var module in visibleData.keys) {
       for (var dataId in visibleData[module]) {
         var filename = "${module.name}.${dataId.name}";
         var assetUri = _tmpFolders[dataId].resolve(filename);
         await File.fromUri(assetUri)
-            .copy(folder.uri.resolve(filename).toFilePath());
+            .copy(stepFolder.uri.resolve(filename).toFilePath());
       }
     }
     if (step.needsSources) {
       for (var uri in module.sources) {
         var originalUri = module.rootUri.resolveUri(uri);
-        var copyUri = folder.uri.resolveUri(uri);
+        var copyUri = stepFolder.uri.resolveUri(uri);
         await File.fromUri(copyUri).create(recursive: true);
         await File.fromUri(originalUri).copy(copyUri.toFilePath());
       }
     }
 
-    await step.execute(module, folder.uri,
+    await step.execute(module, stepFolder.uri,
         (Module m, DataId id) => Uri.parse("${m.name}.${id.name}"));
 
-    var outputFile = File.fromUri(
-        folder.uri.resolve("${module.name}.${step.resultId.name}"));
-    if (!await outputFile.exists()) {
-      throw StateError(
-          "Step '${step.runtimeType}' didn't produce an output file");
+    for (var dataId in step.resultData) {
+      var outputFile =
+          File.fromUri(stepFolder.uri.resolve("${module.name}.${dataId.name}"));
+      if (!await outputFile.exists()) {
+        throw StateError(
+            "Step '${step.runtimeType}' didn't produce an output file");
+      }
+      await outputFile.copy(_tmpFolders[dataId]
+          .resolve("${module.name}.${dataId.name}")
+          .toFilePath());
     }
-    await outputFile.copy(_tmpFolders[step.resultId]
-        .resolve("${module.name}.${step.resultId.name}")
-        .toFilePath());
-    await folder.delete(recursive: true);
+    await stepFolder.delete(recursive: true);
   }
 }
diff --git a/pkg/modular_test/lib/src/memory_pipeline.dart b/pkg/modular_test/lib/src/memory_pipeline.dart
index 27ecbe9..9ad5c43 100644
--- a/pkg/modular_test/lib/src/memory_pipeline.dart
+++ b/pkg/modular_test/lib/src/memory_pipeline.dart
@@ -13,8 +13,8 @@
 typedef SourceProvider = String Function(Uri);
 
 abstract class MemoryModularStep extends ModularStep {
-  Future<Object> execute(Module module, SourceProvider sourceProvider,
-      ModuleDataProvider dataProvider);
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider);
 }
 
 class MemoryPipeline extends Pipeline<MemoryModularStep> {
@@ -55,8 +55,12 @@
         inputSources[uri] = _sources[uri];
       });
     }
-    Object result = await step.execute(module, (Uri uri) => inputSources[uri],
+    Map<DataId, Object> result = await step.execute(
+        module,
+        (Uri uri) => inputSources[uri],
         (Module m, DataId id) => inputData[m][id]);
-    (_results[module] ??= {})[step.resultId] = result;
+    for (var dataId in step.resultData) {
+      (_results[module] ??= {})[dataId] = result[dataId];
+    }
   }
 }
diff --git a/pkg/modular_test/lib/src/pipeline.dart b/pkg/modular_test/lib/src/pipeline.dart
index f20d1be..64e08a7 100644
--- a/pkg/modular_test/lib/src/pipeline.dart
+++ b/pkg/modular_test/lib/src/pipeline.dart
@@ -25,19 +25,19 @@
   /// This can be data produced on a previous stage of the pipeline
   /// or produced by this same step when it was run on a dependency.
   ///
-  /// If this list includes [resultId], then the modular-step has to be run on
-  /// dependencies before it is run on a module. Otherwise, it could be run in
-  /// parallel.
+  /// If this list includes any data from [resultData], then the modular-step
+  /// has to be run on dependencies before it is run on a module. Otherwise, it
+  /// could be run in parallel.
   final List<DataId> dependencyDataNeeded;
 
   /// Data that this step needs to read about the module itself.
   ///
   /// This is meant to be data produced in earlier stages of the modular
-  /// pipeline. It is an error to include [resultId] in this list.
+  /// pipeline. It is an error to include any id from [resultData] in this list.
   final List<DataId> moduleDataNeeded;
 
   /// Data that this step produces.
-  final DataId resultId;
+  final List<DataId> resultData;
 
   /// Whether this step is only executed on the main module.
   final bool onlyOnMain;
@@ -46,7 +46,7 @@
       {this.needsSources: true,
       this.dependencyDataNeeded: const [],
       this.moduleDataNeeded: const [],
-      this.resultId,
+      this.resultData,
       this.onlyOnMain: false});
 }
 
@@ -74,30 +74,35 @@
     // or by the same step on a dependency.
     Map<DataId, S> previousKinds = {};
     for (var step in steps) {
-      var resultKind = step.resultId;
-      if (previousKinds.containsKey(resultKind)) {
-        _validationError("Cannot produce the same data on two modular steps."
-            " '$resultKind' was previously produced by "
-            "'${previousKinds[resultKind].runtimeType}' but "
-            "'${step.runtimeType}' also produces the same data.");
+      if (step.resultData == null || step.resultData.isEmpty) {
+        _validationError(
+            "'${step.runtimeType}' needs to declare what data it produces.");
       }
-      previousKinds[resultKind] = step;
-      for (var dataId in step.dependencyDataNeeded) {
-        if (!previousKinds.containsKey(dataId)) {
-          _validationError(
-              "Step '${step.runtimeType}' needs data '${dataId}', but the data"
-              " is not produced by this or a preceding step.");
+      for (var resultKind in step.resultData) {
+        if (previousKinds.containsKey(resultKind)) {
+          _validationError("Cannot produce the same data on two modular steps."
+              " '$resultKind' was previously produced by "
+              "'${previousKinds[resultKind].runtimeType}' but "
+              "'${step.runtimeType}' also produces the same data.");
         }
-      }
-      for (var dataId in step.moduleDataNeeded) {
-        if (!previousKinds.containsKey(dataId)) {
-          _validationError(
-              "Step '${step.runtimeType}' needs data '${dataId}', but the data"
-              " is not produced by a preceding step.");
+        previousKinds[resultKind] = step;
+        for (var dataId in step.dependencyDataNeeded) {
+          if (!previousKinds.containsKey(dataId)) {
+            _validationError(
+                "Step '${step.runtimeType}' needs data '${dataId}', but the "
+                "data is not produced by this or a preceding step.");
+          }
         }
-        if (dataId == resultKind) {
-          _validationError(
-              "Circular dependency on '$dataId' in step '${step.runtimeType}'");
+        for (var dataId in step.moduleDataNeeded) {
+          if (!previousKinds.containsKey(dataId)) {
+            _validationError(
+                "Step '${step.runtimeType}' needs data '${dataId}', but the "
+                "data is not produced by a preceding step.");
+          }
+          if (dataId == resultKind) {
+            _validationError("Circular dependency on '$dataId' "
+                "in step '${step.runtimeType}'");
+          }
         }
       }
     }
@@ -147,7 +152,7 @@
       }
     }
     await runStep(step, module, visibleData);
-    (computedData[module] ??= {}).add(step.resultId);
+    (computedData[module] ??= {}).addAll(step.resultData);
   }
 
   Future<void> runStep(
diff --git a/pkg/modular_test/test/io_pipeline_test.dart b/pkg/modular_test/test/io_pipeline_test.dart
index 6e26e91..dd3d01f 100644
--- a/pkg/modular_test/test/io_pipeline_test.dart
+++ b/pkg/modular_test/test/io_pipeline_test.dart
@@ -73,6 +73,15 @@
       MainOnlyStep(action, inputId, depId, resultId, requestDependenciesData);
 
   @override
+  IOModularStep createTwoOutputStep(
+          {String Function(String) action1,
+          String Function(String) action2,
+          DataId inputId,
+          DataId result1Id,
+          DataId result2Id}) =>
+      TwoOutputStep(action1, action2, inputId, result1Id, result2Id);
+
+  @override
   String getResult(covariant IOPipeline pipeline, Module m, DataId dataId) {
     var folderUri = pipeline.tmpFoldersForTesting[dataId];
     var file = File.fromUri(folderUri.resolve("${m.name}.${dataId.name}"));
@@ -95,6 +104,7 @@
   final bool needsSources;
   List<DataId> get dependencyDataNeeded => const [];
   List<DataId> get moduleDataNeeded => const [];
+  List<DataId> get resultData => [resultId];
   bool get onlyOnMain => false;
 
   SourceOnlyStep(this.action, this.resultId, this.needsSources);
@@ -119,6 +129,7 @@
   bool get needsSources => false;
   List<DataId> get dependencyDataNeeded => const [];
   final List<DataId> moduleDataNeeded;
+  List<DataId> get resultData => [resultId];
   final DataId resultId;
   final DataId inputId;
   bool get onlyOnMain => false;
@@ -137,10 +148,41 @@
   }
 }
 
+class TwoOutputStep implements IOModularStep {
+  final String Function(String) action1;
+  final String Function(String) action2;
+  bool get needsSources => false;
+  List<DataId> get dependencyDataNeeded => const [];
+  List<DataId> get moduleDataNeeded => [inputId];
+  List<DataId> get resultData => [result1Id, result2Id];
+  final DataId result1Id;
+  final DataId result2Id;
+  final DataId inputId;
+  bool get onlyOnMain => false;
+
+  TwoOutputStep(
+      this.action1, this.action2, this.inputId, this.result1Id, this.result2Id);
+
+  @override
+  Future<void> execute(
+      Module module, Uri root, ModuleDataToRelativeUri toUri) async {
+    var inputData = await _readHelper(module, root, inputId, toUri);
+    var result1 =
+        inputData == null ? "data for $module was null" : action1(inputData);
+    var result2 =
+        inputData == null ? "data for $module was null" : action2(inputData);
+    await File.fromUri(root.resolveUri(toUri(module, result1Id)))
+        .writeAsString(result1);
+    await File.fromUri(root.resolveUri(toUri(module, result2Id)))
+        .writeAsString(result2);
+  }
+}
+
 class LinkStep implements IOModularStep {
   bool get needsSources => false;
   final List<DataId> dependencyDataNeeded;
   List<DataId> get moduleDataNeeded => [inputId];
+  List<DataId> get resultData => [resultId];
   final String Function(String, List<String>) action;
   final DataId inputId;
   final DataId depId;
@@ -169,6 +211,7 @@
   bool get needsSources => false;
   final List<DataId> dependencyDataNeeded;
   List<DataId> get moduleDataNeeded => [inputId];
+  List<DataId> get resultData => [resultId];
   final String Function(String, List<String>) action;
   final DataId inputId;
   final DataId depId;
diff --git a/pkg/modular_test/test/memory_pipeline_test.dart b/pkg/modular_test/test/memory_pipeline_test.dart
index 94e53dd..ba406e1 100644
--- a/pkg/modular_test/test/memory_pipeline_test.dart
+++ b/pkg/modular_test/test/memory_pipeline_test.dart
@@ -60,6 +60,15 @@
       MainOnlyStep(action, inputId, depId, resultId, requestDependenciesData);
 
   @override
+  MemoryModularStep createTwoOutputStep(
+          {String Function(String) action1,
+          String Function(String) action2,
+          DataId inputId,
+          DataId result1Id,
+          DataId result2Id}) =>
+      TwoOutputStep(action1, action2, inputId, result1Id, result2Id);
+
+  @override
   String getResult(covariant MemoryPipeline pipeline, Module m, DataId dataId) {
     return pipeline.resultsForTesting[m][dataId];
   }
@@ -73,17 +82,18 @@
   final bool needsSources;
   List<DataId> get dependencyDataNeeded => const [];
   List<DataId> get moduleDataNeeded => const [];
+  List<DataId> get resultData => [resultId];
   bool get onlyOnMain => false;
 
   SourceOnlyStep(this.action, this.resultId, this.needsSources);
 
-  Future<Object> execute(Module module, SourceProvider sourceProvider,
-      ModuleDataProvider dataProvider) {
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider) {
     Map<Uri, String> sources = {};
     for (var uri in module.sources) {
       sources[uri] = sourceProvider(module.rootUri.resolveUri(uri));
     }
-    return Future.value(action(sources));
+    return Future.value({resultId: action(sources)});
   }
 }
 
@@ -92,6 +102,7 @@
   bool get needsSources => false;
   List<DataId> get dependencyDataNeeded => const [];
   final List<DataId> moduleDataNeeded;
+  List<DataId> get resultData => [resultId];
   final DataId resultId;
   final DataId inputId;
   bool get onlyOnMain => false;
@@ -99,11 +110,40 @@
   ModuleDataStep(this.action, this.inputId, this.resultId, bool requestInput)
       : moduleDataNeeded = requestInput ? [inputId] : [];
 
-  Future<Object> execute(Module module, SourceProvider sourceProvider,
-      ModuleDataProvider dataProvider) {
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider) {
     var inputData = dataProvider(module, inputId) as String;
-    if (inputData == null) return Future.value("data for $module was null");
-    return Future.value(action(inputData));
+    if (inputData == null)
+      return Future.value({resultId: "data for $module was null"});
+    return Future.value({resultId: action(inputData)});
+  }
+}
+
+class TwoOutputStep implements MemoryModularStep {
+  final String Function(String) action1;
+  final String Function(String) action2;
+  bool get needsSources => false;
+  List<DataId> get dependencyDataNeeded => const [];
+  List<DataId> get moduleDataNeeded => [inputId];
+  List<DataId> get resultData => [result1Id, result2Id];
+  final DataId result1Id;
+  final DataId result2Id;
+  final DataId inputId;
+  bool get onlyOnMain => false;
+
+  TwoOutputStep(
+      this.action1, this.action2, this.inputId, this.result1Id, this.result2Id);
+
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider) {
+    var inputData = dataProvider(module, inputId) as String;
+    if (inputData == null)
+      return Future.value({
+        result1Id: "data for $module was null",
+        result2Id: "data for $module was null",
+      });
+    return Future.value(
+        {result1Id: action1(inputData), result2Id: action2(inputData)});
   }
 }
 
@@ -115,19 +155,20 @@
   final DataId inputId;
   final DataId depId;
   final DataId resultId;
+  List<DataId> get resultData => [resultId];
   bool get onlyOnMain => false;
 
   LinkStep(this.action, this.inputId, this.depId, this.resultId,
       bool requestDependencies)
       : dependencyDataNeeded = requestDependencies ? [depId] : [];
 
-  Future<Object> execute(Module module, SourceProvider sourceProvider,
-      ModuleDataProvider dataProvider) {
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider) {
     List<String> depsData = module.dependencies
         .map((d) => dataProvider(d, depId) as String)
         .toList();
     var inputData = dataProvider(module, inputId) as String;
-    return Future.value(action(inputData, depsData));
+    return Future.value({resultId: action(inputData, depsData)});
   }
 }
 
@@ -139,18 +180,19 @@
   final DataId inputId;
   final DataId depId;
   final DataId resultId;
+  List<DataId> get resultData => [resultId];
   bool get onlyOnMain => true;
 
   MainOnlyStep(this.action, this.inputId, this.depId, this.resultId,
       bool requestDependencies)
       : dependencyDataNeeded = requestDependencies ? [depId] : [];
 
-  Future<Object> execute(Module module, SourceProvider sourceProvider,
-      ModuleDataProvider dataProvider) {
+  Future<Map<DataId, Object>> execute(Module module,
+      SourceProvider sourceProvider, ModuleDataProvider dataProvider) {
     List<String> depsData = computeTransitiveDependencies(module)
         .map((d) => dataProvider(d, depId) as String)
         .toList();
     var inputData = dataProvider(module, inputId) as String;
-    return Future.value(action(inputData, depsData));
+    return Future.value({resultId: action(inputData, depsData)});
   }
 }
diff --git a/pkg/modular_test/test/pipeline_common.dart b/pkg/modular_test/test/pipeline_common.dart
index ceb2a3a..b09e611 100644
--- a/pkg/modular_test/test/pipeline_common.dart
+++ b/pkg/modular_test/test/pipeline_common.dart
@@ -70,6 +70,15 @@
       DataId resultId,
       bool requestDependenciesData: true});
 
+  /// Create a step that applies [action1] and [action2] on the module [inputId]
+  /// data, and emits two results with the given [result1Id] and [result2Id].
+  S createTwoOutputStep(
+      {String Function(String) action1,
+      String Function(String) action2,
+      DataId inputId,
+      DataId result1Id,
+      DataId result2Id});
+
   /// Return the result data produced by a modular step.
   String getResult(Pipeline<S> pipeline, Module m, DataId dataId);
 
@@ -104,7 +113,7 @@
         testStrategy.createSourceOnlyStep(action: _concat, resultId: _concatId);
     var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
     await pipeline.run(singleModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, concatStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _concatId),
         "a1.dart: A1\na2.dart: A2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -114,7 +123,7 @@
         action: _concat, resultId: _concatId, requestSources: false);
     var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
     await pipeline.run(singleModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, concatStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _concatId),
         "a1.dart: null\na2.dart: null\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -124,9 +133,9 @@
         testStrategy.createSourceOnlyStep(action: _concat, resultId: _concatId);
     var pipeline = await testStrategy.createPipeline(sources, <S>[concatStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, concatStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _concatId),
         "a1.dart: A1\na2.dart: A2\n");
-    expect(testStrategy.getResult(pipeline, m2, concatStep.resultId),
+    expect(testStrategy.getResult(pipeline, m2, _concatId),
         "b/b1.dart: B1\nb/b2.dart: B2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -139,9 +148,9 @@
     var pipeline = await testStrategy
         .createPipeline(sources, <S>[concatStep, lowercaseStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, lowercaseStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _lowercaseId),
         "a1.dart: a1\na2.dart: a2\n");
-    expect(testStrategy.getResult(pipeline, m2, lowercaseStep.resultId),
+    expect(testStrategy.getResult(pipeline, m2, _lowercaseId),
         "b/b1.dart: b1\nb/b2.dart: b2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -157,13 +166,32 @@
     var pipeline = await testStrategy
         .createPipeline(sources, <S>[concatStep, lowercaseStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, lowercaseStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _lowercaseId),
         "data for [module a] was null");
-    expect(testStrategy.getResult(pipeline, m2, lowercaseStep.resultId),
+    expect(testStrategy.getResult(pipeline, m2, _lowercaseId),
         "data for [module b] was null");
     await testStrategy.cleanup(pipeline);
   });
 
+  test('all outputs of a step are created together', () async {
+    var concatStep =
+        testStrategy.createSourceOnlyStep(action: _concat, resultId: _concatId);
+    var twoOutputStep = testStrategy.createTwoOutputStep(
+        action1: _lowercase,
+        action2: _uppercase,
+        inputId: _concatId,
+        result1Id: _lowercaseId,
+        result2Id: _uppercaseId);
+    var pipeline = await testStrategy
+        .createPipeline(sources, <S>[concatStep, twoOutputStep]);
+    await pipeline.run(twoModuleInput);
+    expect(testStrategy.getResult(pipeline, m2, _lowercaseId),
+        "b/b1.dart: b1\nb/b2.dart: b2\n");
+    expect(testStrategy.getResult(pipeline, m2, _uppercaseId),
+        "B/B1.DART: B1\nB/B2.DART: B2\n");
+    await testStrategy.cleanup(pipeline);
+  });
+
   test('can read same-step results of dependencies if requested', () async {
     var concatStep =
         testStrategy.createSourceOnlyStep(action: _concat, resultId: _concatId);
@@ -177,9 +205,8 @@
     var pipeline = await testStrategy.createPipeline(
         sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultId),
-        "a1 a1\na2 a2\n");
-    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _joinId), "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, _joinId),
         "a1 a1\na2 a2\n\nb/b1 b1\nb/b2 b2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -199,9 +226,8 @@
     var pipeline = await testStrategy.createPipeline(
         sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultId),
-        "a1 a1\na2 a2\n");
-    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _joinId), "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, _joinId),
         "null\nb/b1 b1\nb/b2 b2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -219,9 +245,8 @@
     var pipeline = await testStrategy.createPipeline(
         sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultId),
-        "a1 a1\na2 a2\n");
-    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _joinId), "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, _joinId),
         "a1.dart: a1\na2.dart: a2\n\nb/b1 b1\nb/b2 b2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -241,9 +266,8 @@
     var pipeline = await testStrategy.createPipeline(
         sources, <S>[concatStep, lowercaseStep, replaceJoinStep]);
     await pipeline.run(twoModuleInput);
-    expect(testStrategy.getResult(pipeline, m1, replaceJoinStep.resultId),
-        "a1 a1\na2 a2\n");
-    expect(testStrategy.getResult(pipeline, m2, replaceJoinStep.resultId),
+    expect(testStrategy.getResult(pipeline, m1, _joinId), "a1 a1\na2 a2\n");
+    expect(testStrategy.getResult(pipeline, m2, _joinId),
         "null\nb/b1 b1\nb/b2 b2\n");
     await testStrategy.cleanup(pipeline);
   });
@@ -290,6 +314,7 @@
 
 DataId _concatId = const DataId("concat");
 DataId _lowercaseId = const DataId("lowercase");
+DataId _uppercaseId = const DataId("uppercase");
 DataId _joinId = const DataId("join");
 
 String _concat(Map<Uri, String> sources) {
@@ -301,6 +326,7 @@
 }
 
 String _lowercase(String contents) => contents.toLowerCase();
+String _uppercase(String contents) => contents.toUpperCase();
 
 String _replaceAndJoin(String moduleData, List<String> depContents) {
   var buffer = new StringBuffer();
diff --git a/pkg/modular_test/test/validate_test.dart b/pkg/modular_test/test/validate_test.dart
index 152271a..8394dfe 100644
--- a/pkg/modular_test/test/validate_test.dart
+++ b/pkg/modular_test/test/validate_test.dart
@@ -18,12 +18,12 @@
     var id3 = DataId("data_c");
     validateSteps([
       ModularStep(
-          needsSources: true, dependencyDataNeeded: [id1], resultId: id1),
-      ModularStep(moduleDataNeeded: [id1], resultId: id2),
+          needsSources: true, dependencyDataNeeded: [id1], resultData: [id1]),
+      ModularStep(moduleDataNeeded: [id1], resultData: [id2]),
       ModularStep(
           moduleDataNeeded: [id2],
           dependencyDataNeeded: [id1, id3],
-          resultId: id3),
+          resultData: [id3]),
     ]);
   });
 
@@ -31,23 +31,31 @@
     var id1 = DataId("data_a");
     expect(
         () => validateSteps([
-              ModularStep(moduleDataNeeded: [id1], resultId: id1),
+              ModularStep(moduleDataNeeded: [id1], resultData: [id1]),
             ]),
         throwsA(TypeMatcher<InvalidPipelineError>()));
   });
 
+  test('some results must be declared', () {
+    expect(() => validateSteps([ModularStep()]),
+        throwsA(TypeMatcher<InvalidPipelineError>()));
+    expect(() => validateSteps([ModularStep(resultData: [])]),
+        throwsA(TypeMatcher<InvalidPipelineError>()));
+  });
+
   test('out of order dependencies are not allowed', () {
     var id1 = DataId("data_a");
     var id2 = DataId("data_b");
     validateSteps([
-      ModularStep(resultId: id1), // id1 must be produced before it is consumed.
-      ModularStep(dependencyDataNeeded: [id1], resultId: id2),
+      // id1 must be produced before it is consumed.
+      ModularStep(resultData: [id1]),
+      ModularStep(dependencyDataNeeded: [id1], resultData: [id2]),
     ]);
 
     expect(
         () => validateSteps([
-              ModularStep(dependencyDataNeeded: [id1], resultId: id2),
-              ModularStep(resultId: id1),
+              ModularStep(dependencyDataNeeded: [id1], resultData: [id2]),
+              ModularStep(resultData: [id1]),
             ]),
         throwsA(TypeMatcher<InvalidPipelineError>()));
   });
@@ -56,8 +64,8 @@
     var id1 = DataId("data_a");
     expect(
         () => validateSteps([
-              ModularStep(resultId: id1),
-              ModularStep(resultId: id1),
+              ModularStep(resultData: [id1]),
+              ModularStep(resultData: [id1]),
             ]),
         throwsA(TypeMatcher<InvalidPipelineError>()));
   });
diff --git a/tests/compiler/dart2js/modular/modular_test.dart b/tests/compiler/dart2js/modular/modular_test.dart
index 2f98259..eb7c1d7 100644
--- a/tests/compiler/dart2js/modular/modular_test.dart
+++ b/tests/compiler/dart2js/modular/modular_test.dart
@@ -52,11 +52,12 @@
 
 const dillId = const DataId("dill");
 const jsId = const DataId("js");
+const txtId = const DataId("txt");
 
 // Step that compiles sources in a module to a .dill file.
 class SourceToDillStep implements IOModularStep {
   @override
-  DataId get resultId => dillId;
+  List<DataId> get resultData => const [dillId];
 
   @override
   bool get needsSources => true;
@@ -155,7 +156,7 @@
 // all transitive modules as inputs.
 class CompileFromDillStep implements IOModularStep {
   @override
-  DataId get resultId => jsId;
+  List<DataId> get resultData => const [jsId];
 
   @override
   bool get needsSources => false;
@@ -182,7 +183,7 @@
       'package:compiler/src/dart2js.dart',
       '${toUri(module, dillId)}',
       '--dill-dependencies=${dillDependencies.join(',')}',
-      '--out=${toUri(module, resultId)}',
+      '--out=${toUri(module, jsId)}',
     ];
     var result =
         await _runProcess(Platform.resolvedExecutable, args, root.toFilePath());
@@ -194,7 +195,7 @@
 /// Step that runs the output of dart2js in d8 and saves the output.
 class RunD8 implements IOModularStep {
   @override
-  DataId get resultId => const DataId("txt");
+  List<DataId> get resultData => const [txtId];
 
   @override
   bool get needsSources => false;
@@ -224,7 +225,7 @@
 
     _checkExitCode(result, this, module);
 
-    await File.fromUri(root.resolveUri(toUri(module, resultId)))
+    await File.fromUri(root.resolveUri(toUri(module, txtId)))
         .writeAsString(result.stdout);
   }
 }