Implement LcovMerger.

LcovMerger is a tool that merges all the intermediate lcov tracefiles (with .dat extension) found under a coverage directory and prints the merged tracefile to a given output file.

A custom implementation for merging lcov tracefiles is needed because the merging functionality of lcov itself is very slow.

LcovMerger is required to get a single coverage report (lcov tracefile) from a bazel coverage command that executes multiple tests.

ATM LcovMerger is only invoked by tools/test/collect_coverage.sh that collects and merges the tracefiles from a single test invocation. It will also be used from a CoverageReportAction.

Progress on #5246.

PiperOrigin-RevId: 200054506
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/java/BazelJavaTestRule.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/java/BazelJavaTestRule.java
index ebb64f2..dbda63a 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/java/BazelJavaTestRule.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/java/BazelJavaTestRule.java
@@ -66,7 +66,9 @@
         // Input files for test actions collecting code coverage
         .add(
             attr("$lcov_merger", LABEL)
-                .value(env.getLabel("@bazel_tools//tools/test:LcovMerger")))
+                .value(env.getLabel(
+                    "@bazel_tools//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:Main"
+                )))
         .add(
             attr("$jacocorunner", LABEL)
                 .value(
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java b/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
index 3b75799..d64c9b1 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
@@ -131,10 +131,15 @@
         "filegroup(name = 'runtime', srcs = ['test-setup.sh'])",
         "filegroup(name = 'test_setup', srcs = ['test-setup.sh'])",
         "filegroup(name = 'collect_coverage', srcs = ['collect_coverage.sh'])",
-        "filegroup(name='coverage_support', srcs=['collect_coverage.sh','LcovMerger'])",
+        "filegroup(name='coverage_support', srcs=['collect_coverage.sh'])",
         "filegroup(name = 'coverage_report_generator', srcs = ['coverage_report_generator.sh'])");
 
     config.create(
+        "/bazel_tools_workspace/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BUILD",
+        "filegroup(name='srcs', srcs = glob(['**']))",
+        "filegroup(name='Main', srcs = ['Main.java'])");
+
+    config.create(
         "/bazel_tools_workspace/tools/python/BUILD",
         "package(default_visibility=['//visibility:public'])",
         "exports_files(['precompile.py'])",
diff --git a/src/test/shell/bazel/bazel_coverage_test.sh b/src/test/shell/bazel/bazel_coverage_test.sh
index 54e4b30..17f1f7f 100755
--- a/src/test/shell/bazel/bazel_coverage_test.sh
+++ b/src/test/shell/bazel/bazel_coverage_test.sh
@@ -206,12 +206,12 @@
   cat <<EOF > result.dat
 SF:com/example/Collatz.java
 FN:3,com/example/Collatz::<init> ()V
-FNDA:0,com/example/Collatz::<init> ()V
 FN:6,com/example/Collatz::getCollatzFinal (I)I
+FNDA:0,com/example/Collatz::<init> ()V
 FNDA:1,com/example/Collatz::getCollatzFinal (I)I
+FNF:0
+FNH:0
 BA:6,2
-BA:6,2
-BA:9,2
 BA:9,2
 DA:3,0
 DA:6,3
@@ -219,6 +219,8 @@
 DA:9,4
 DA:10,5
 DA:12,7
+LH:0
+LF:0
 end_of_record
 EOF
 
@@ -297,12 +299,12 @@
   cat <<EOF > result.dat
 SF:src/main/com/example/Collatz.java
 FN:3,com/example/Collatz::<init> ()V
-FNDA:0,com/example/Collatz::<init> ()V
 FN:6,com/example/Collatz::getCollatzFinal (I)I
+FNDA:0,com/example/Collatz::<init> ()V
 FNDA:1,com/example/Collatz::getCollatzFinal (I)I
+FNF:0
+FNH:0
 BA:6,2
-BA:6,2
-BA:9,2
 BA:9,2
 DA:3,0
 DA:6,3
@@ -310,6 +312,8 @@
 DA:9,4
 DA:10,5
 DA:12,7
+LH:0
+LF:0
 end_of_record
 EOF
 
diff --git a/tools/BUILD b/tools/BUILD
index 0f28085..84e96b1 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -31,6 +31,8 @@
         "//tools/osx:srcs",
         "//tools/osx/crosstool:srcs",
         "//tools/test:srcs",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:srcs",
+        "//tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger:srcs",
         "//tools/python:srcs",
         "//tools/runfiles:srcs",
         "//tools/sh:srcs",
@@ -65,10 +67,12 @@
         "//tools/python:embedded_tools",
         "//tools/runfiles:embedded_tools",
         "//tools/test:srcs",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:srcs",
         "//tools/osx/crosstool:srcs",
         "//tools/osx:srcs",
         "//tools/sh:embedded_tools",
         "//tools/whitelists:srcs",
         "//tools/zip:srcs",
+        "//third_party:srcs",
     ],
 )
diff --git a/tools/test/BUILD b/tools/test/BUILD
index bc70912..3f7d28b 100644
--- a/tools/test/BUILD
+++ b/tools/test/BUILD
@@ -17,30 +17,6 @@
     srcs = ["collect_coverage.sh"],
 )
 
-java_binary(
-    name = "LcovMerger",
-    srcs = glob(["LcovMerger/java/**/*.java"]),
-    main_class = "com.google.devtools.lcovmerger.Main",
-)
-
-java_library(
-    name = "LcovMergerTestUtils",
-    srcs = glob(["LcovMerger/java/**/*.java"]),
-)
-
-java_test(
-    name = "LcovMergerTest",
-    srcs = glob(["LcovMerger/javatests/**/*.java"]),
-    deps = [
-        ":LcovMergerTestUtils",
-        "//src/main/java/com/google/devtools/build/lib/vfs",
-        "//src/test/java/com/google/devtools/build/lib:foundations_testutil",
-        "//src/test/java/com/google/devtools/build/lib:testutil",
-        "//third_party:junit4",
-        "//third_party:truth",
-    ],
-)
-
 filegroup(
     name = "coverage_support",
     srcs = ["collect_coverage.sh"],
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BUILD b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BUILD
new file mode 100644
index 0000000..b7d4d7b
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BUILD
@@ -0,0 +1,106 @@
+package(
+    default_visibility = [
+        "//tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger:__pkg__",
+    ],
+)
+
+licenses(["notice"])  # Apache 2.0
+
+java_library(
+    name = "BranchCoverage",
+    srcs = ["BranchCoverage.java"],
+    deps = [
+        "//third_party:auto_value",
+    ],
+)
+
+java_library(
+    name = "LineCoverage",
+    srcs = ["LineCoverage.java"],
+    deps = [
+        "//third_party:auto_value",
+        "//third_party:jsr305",
+    ],
+)
+
+java_library(
+    name = "SourceFileCoverage",
+    srcs = ["SourceFileCoverage.java"],
+    deps = [
+        ":BranchCoverage",
+        ":LineCoverage",
+        "//third_party:auto_value",
+        "//third_party:guava",
+        "//third_party:jsr305",
+    ],
+)
+
+java_library(
+    name = "LcovPrinter",
+    srcs = ["LcovPrinter.java"],
+    deps = [
+        ":BranchCoverage",
+        ":Coverage",
+        ":LcovConstants",
+        ":LineCoverage",
+        ":SourceFileCoverage",
+        "//third_party:guava",
+    ],
+)
+
+java_library(
+    name = "LcovConstants",
+    srcs = ["LcovConstants.java"],
+)
+
+java_library(
+    name = "LcovParser",
+    srcs = ["LcovParser.java"],
+    deps = [
+        ":BranchCoverage",
+        ":LcovConstants",
+        ":LineCoverage",
+        ":SourceFileCoverage",
+    ],
+)
+
+java_library(
+    name = "Coverage",
+    srcs = ["Coverage.java"],
+    deps = [":SourceFileCoverage"],
+)
+
+java_library(
+    name = "MainLibrary",
+    srcs = ["Main.java"],
+    deps = [
+        ":Coverage",
+        ":LcovConstants",
+        ":LcovParser",
+        ":LcovPrinter",
+        ":SourceFileCoverage",
+        "//third_party:guava",
+    ],
+)
+
+java_binary(
+    name = "Main",
+    srcs = ["Main.java"],
+    main_class = "com.google.devtools.lcovmerger.Main",
+    visibility = ["//visibility:public"],
+    deps = [
+        ":Coverage",
+        ":LcovConstants",
+        ":LcovParser",
+        ":LcovPrinter",
+        ":MainLibrary",
+        ":SourceFileCoverage",
+        "//third_party:guava",
+    ],
+)
+
+filegroup(
+    name = "srcs",
+    srcs = glob(["**"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BranchCoverage.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BranchCoverage.java
new file mode 100644
index 0000000..3b469ff
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/BranchCoverage.java
@@ -0,0 +1,57 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Stores branch coverage information.
+ */
+@AutoValue
+abstract class BranchCoverage {
+
+  static BranchCoverage create(
+      int lineNumber, int blockNumber, int branchNumber, boolean wasExecuted, int nrOfExecutions) {
+    assert (wasExecuted && nrOfExecutions > 0) || (!wasExecuted && nrOfExecutions == 0);
+    return new AutoValue_BranchCoverage(
+        lineNumber, blockNumber, branchNumber, wasExecuted, nrOfExecutions);
+  }
+
+  /**
+   * Merges two given instances of {@link BranchCoverage}.
+   *
+   * Calling {@code lineNumber()}, {@code blockNumber()} and {@code branchNumber()} must return the
+   * same values for {@code first} and {@code second}.
+   */
+  static BranchCoverage merge(BranchCoverage first, BranchCoverage second) {
+    assert first.lineNumber() == second.lineNumber();
+    assert first.blockNumber() == second.blockNumber();
+    assert first.branchNumber() == second.branchNumber();
+
+    return create(
+        first.lineNumber(),
+        first.blockNumber(),
+        first.branchNumber(),
+        first.wasExecuted() || second.wasExecuted(),
+        first.nrOfExecutions() + second.nrOfExecutions());
+  }
+
+  abstract int lineNumber();
+  // The two numbers below should be -1 for non-gcc emitted coverage (e.g. Java).
+  abstract int blockNumber();  // internal gcc internal ID for the branch
+  abstract int branchNumber(); // internal gcc internal ID for the branch
+  abstract boolean wasExecuted();
+  abstract int nrOfExecutions();
+}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Coverage.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Coverage.java
new file mode 100644
index 0000000..82733aa
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Coverage.java
@@ -0,0 +1,40 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import java.util.Collection;
+import java.util.TreeMap;
+
+class Coverage {
+  private final TreeMap<String, SourceFileCoverage> sourceFiles;
+
+  Coverage() {
+    sourceFiles = new TreeMap<>();
+  }
+
+  void add(SourceFileCoverage input) {
+    String sourceFilename = input.sourceFileName();
+    if (sourceFiles.containsKey(sourceFilename)) {
+      SourceFileCoverage old = sourceFiles.get(sourceFilename);
+      sourceFiles.put(sourceFilename, SourceFileCoverage.merge(old, input));
+    } else {
+      sourceFiles.put(sourceFilename, input);
+    }
+  }
+
+  Collection<SourceFileCoverage> getAllSourceFiles() {
+    return sourceFiles.values();
+  }
+}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovConstants.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovConstants.java
new file mode 100644
index 0000000..83c6ce5
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovConstants.java
@@ -0,0 +1,38 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+/**
+ * Stores markers used by the lcov tracefile. See
+ * <a href="http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php"> lcov documentation</a>
+ */
+class LcovConstants {
+  static final String SF_MARKER = "SF:";
+  static final String FN_MARKER = "FN:";
+  static final String FNDA_MARKER = "FNDA:";
+  static final String FNF_MARKER = "FNF:";
+  static final String FNH_MARKER = "FNH:";
+  static final String BRDA_MARKER = "BRDA:";
+  static final String BA_MARKER = "BA:";
+  static final String BRF_MARKER = "BRF:";
+  static final String BRH_MARKER = "BRH:";
+  static final String DA_MARKER = "DA:";
+  static final String LH_MARKER = "LH:";
+  static final String LF_MARKER = "LF:";
+  static final String END_OF_RECORD_MARKER = "end_of_record";
+  static final String LCOV_DELIMITER = ",";
+  static final String TAKEN = "-";
+  static final String TRACEFILE_EXTENSION = ".dat";
+}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovMerger.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovMerger.java
deleted file mode 100644
index ebad7eb..0000000
--- a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovMerger.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright 2016 The Bazel Authors. All rights reserved.
-//
-// 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.devtools.lcovmerger;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-/**
- * A component that converts language specific raw coverage data to pseudo lcov format.
- */
-class LcovMerger {
-  private static final Logger logger = Logger.getLogger(LcovMerger.class.getName());
-
-  private final String outputFile;
-  private final List<File> fileList;
-
-  /**
-   * Constructs an {@link LcovMerger} and collects the raw coverage files.
-   */
-  LcovMerger(String originalCoverageFilesDirectory, String generatedCoverageDataOutputPath) {
-    this.fileList = getDatFiles(originalCoverageFilesDirectory);
-    this.outputFile = generatedCoverageDataOutputPath;
-  }
-
-  /**
-   * Merge all files in {@link fileList} and write to {@link outputFile}.
-   *
-   * @return successful or not
-   */
-  boolean merge() {
-    if (fileList.isEmpty()) {
-      logger.log(Level.SEVERE, "No lcov file found.");
-      return false;
-    }
-    if (fileList.size() > 1) {
-      logger.log(Level.SEVERE, "Only one lcov file supported now, but found " + fileList.size());
-      return false;
-    }
-    try {
-      Files.copy(
-          fileList.get(0).toPath(), Paths.get(outputFile), StandardCopyOption.REPLACE_EXISTING);
-    } catch (IOException e) {
-      logger.log(Level.SEVERE, "Failed to copy file: " + e.getMessage());
-      return false;
-    }
-    return true;
-  }
-
-  private List<File> getDatFiles(String coverageDir) {
-    List<File> datFiles = new ArrayList<>();
-    try (DirectoryStream<Path> stream = Files.newDirectoryStream(Paths.get(coverageDir), "*.dat")) {
-      for (Path entry : stream) {
-        datFiles.add(entry.toFile());
-      }
-    } catch (IOException x) {
-      logger.log(Level.SEVERE, "error reading folder " + coverageDir + ": " + x.getMessage());
-    }
-    return datFiles;
-  }
-}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovParser.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovParser.java
new file mode 100644
index 0000000..ec6a3ee
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovParser.java
@@ -0,0 +1,410 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.devtools.lcovmerger.LcovConstants.BA_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.BRDA_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.BRF_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.BRH_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.DA_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.END_OF_RECORD_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.FNDA_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.FNF_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.FNH_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.FN_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.LCOV_DELIMITER;
+import static com.google.devtools.lcovmerger.LcovConstants.LF_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.LH_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.SF_MARKER;
+import static com.google.devtools.lcovmerger.LcovConstants.TAKEN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A parser for the lcov tracefile format used by geninfo. See
+ * <a href="http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php"> lcov documentation</a>
+ */
+class LcovParser {
+
+  private static final Logger logger = Logger.getLogger(LcovParser.class.getName());
+  private SourceFileCoverage currentSourceFileCoverage;
+  private final InputStream tracefileStream;
+
+  private LcovParser(InputStream tracefileStream) {
+    this.tracefileStream = tracefileStream;
+  }
+
+  /**
+   * Returns a list of the source files found in the given tracefile.
+   */
+  public static List<SourceFileCoverage> parse(InputStream tracefileStream) throws IOException {
+    LcovParser lcovParser = new LcovParser(tracefileStream);
+    return lcovParser.parse();
+  }
+
+  /**
+   * Reads the tracefile line by line and creates a SourceFileCoverage object
+   * for each section of the file between a SF:<source file> line and an
+   * end_of_record line.
+   *
+   * @return a list of each source file path found in the tracefile
+   */
+  private List<SourceFileCoverage> parse() throws IOException {
+    List<SourceFileCoverage> allSourceFiles = new ArrayList<>();
+    try (BufferedReader bufferedReader =
+        new BufferedReader(new InputStreamReader(tracefileStream, UTF_8))) {
+      String line;
+      while ((line = bufferedReader.readLine()) != null) {
+        parseLine(line, allSourceFiles);
+      }
+      bufferedReader.close();
+    }
+    return allSourceFiles;
+  }
+
+  /**
+   * Merges {@code currentSourceFileCoverage} into {@code allSourceFilesCoverageData} and resets
+   * {@code currentSourceFileCoverage} to null.
+   */
+  private void reset(List<SourceFileCoverage> allSourceFiles) {
+    allSourceFiles.add(currentSourceFileCoverage);
+    currentSourceFileCoverage = null;
+  }
+
+  /*
+   * Reads the line and redirects the parsing to the corresponding {@code parseXLine} method. Every
+   * {@code parseXLine} methods fills in data to {@code currentSourceFileCoverage} accordingly.
+   */
+  private boolean parseLine(String line, List<SourceFileCoverage> allSourceFiles) {
+    if (line.startsWith(SF_MARKER)) {
+      return parseSFLine(line);
+    }
+    // currentSourceFileCoverage should be null only before calling an SF line, otherwise
+    // the object should have been created in parseSFLine. If currentSourceFileCoverage is null
+    // here it means the parser arrived in an invalid state.
+    if (currentSourceFileCoverage == null) {
+      return false;
+    }
+    if (line.startsWith(FN_MARKER)) {
+      return parseFNLine(line);
+    }
+    if (line.startsWith(FNDA_MARKER)) {
+      return parseFNDALine(line);
+    }
+    if (line.startsWith(FNF_MARKER)) {
+      return parseFNFLine(line);
+    }
+    if (line.startsWith(FNH_MARKER)) {
+      return parseFNHLine(line);
+    }
+    if (line.startsWith(BRDA_MARKER)) {
+      return parseBRDALine(line);
+    }
+    if (line.startsWith(BA_MARKER)) {
+      return parseBALine(line);
+    }
+    if (line.startsWith(BRF_MARKER)) {
+      return parseBRFLine(line);
+    }
+    if (line.startsWith(BRH_MARKER)) {
+      return parseBRHLine(line);
+    }
+    if (line.startsWith(DA_MARKER)) {
+      return parseDALine(line);
+    }
+    if (line.startsWith(LH_MARKER)) {
+      return parseLHLine(line);
+    }
+    if (line.startsWith(LF_MARKER)) {
+      return parseLFLine(line);
+    }
+    if (line.equals(END_OF_RECORD_MARKER)) {
+      reset(allSourceFiles);
+      return true;
+    }
+    logger.log(Level.WARNING, "Tracefile includes invalid line: " + line);
+    return false;
+  }
+
+  // SF:<path to source file name>
+  private boolean parseSFLine(String line) {
+    if (currentSourceFileCoverage != null) {
+      logger.log(Level.WARNING, "Tracefile doesn't have SF:<source file> line before" + line);
+      return false;
+    }
+    String sourcefile = line.substring(SF_MARKER.length());
+    if (sourcefile.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile doesn't contain source file name on line: " + line);
+      return false;
+    }
+    currentSourceFileCoverage = new SourceFileCoverage(sourcefile);
+    return true;
+  }
+
+  // FN:<line number of function start>,<function name>
+  private boolean parseFNLine(String line) {
+    String lineContent = line.substring(FN_MARKER.length());
+    String[] funcData = lineContent.split(LCOV_DELIMITER, -1);
+    if (funcData.length != 2 || funcData[0].isEmpty() || funcData[1].isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid FN line " + line);
+      return false;
+    }
+    try {
+      int lineNrFunctionStart = Integer.parseInt(funcData[0]);
+      String functionName = funcData[1];
+      currentSourceFileCoverage.addLineNumber(functionName, lineNrFunctionStart);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains invalid line number on FN line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // FNDA:<execution count>,<function name>
+  private boolean parseFNDALine(String line) {
+    String lineContent = line.substring(FNDA_MARKER.length());
+    String[] funcData = lineContent.split(LCOV_DELIMITER, -1);
+    if (funcData.length != 2 || funcData[0].isEmpty() || funcData[1].isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid FNDA line " + line);
+      return false;
+    }
+    try {
+      int executionCount = Integer.parseInt(funcData[0]);
+      String functionName = funcData[1];
+      currentSourceFileCoverage.addFunctionExecution(functionName, executionCount);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains invalid execution count on FN line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // FNF:<number of functions found>
+  private boolean parseFNFLine(String line) {
+    String lineContent = line.substring(FNF_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid FNF line " + line);
+      return false;
+    }
+    try {
+      int nrFunctionsFound = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrFunctionsFound(nrFunctionsFound);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING,
+          "Tracefile contains invalid number of functions on FNF line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // FNH:<number of function hit>
+  private boolean parseFNHLine(String line) {
+    String lineContent = line.substring(FNH_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid FNH line " + line);
+      return false;
+    }
+    try {
+      int nrFunctionsHit = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrFunctionsHit(nrFunctionsHit);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING,
+          "Tracefile contains invalid number of functions hit on FNH line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // BA:<line number>,<taken>
+  private boolean parseBALine(String line) {
+    String lineContent = line.substring(BA_MARKER.length());
+    String[] lineData = lineContent.split(LCOV_DELIMITER, -1);
+    if (lineData.length != 2) {
+      logger.log(Level.WARNING, "Tracefile contains invalid BRDA line " + line);
+      return false;
+    }
+    for (String data : lineData) {
+      if (data.isEmpty()) {
+        logger.log(Level.WARNING, "Tracefile contains invalid BRDA line " + line);
+        return false;
+      }
+    }
+    try {
+      int lineNumber = Integer.parseInt(lineData[0]);
+      int taken = Integer.parseInt(lineData[1]);
+
+      boolean wasExecuted = false;
+      if (taken == 1 || taken == 2) {
+        wasExecuted = true;
+      }
+      BranchCoverage branchCoverage =
+          BranchCoverage.create(lineNumber, -1, -1, wasExecuted, taken);
+
+      currentSourceFileCoverage.addBranch(lineNumber, branchCoverage);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains an invalid number BA line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // BRDA:<line number>,<block number>,<branch number>,<taken>
+  private boolean parseBRDALine(String line) {
+    String lineContent = line.substring(BRDA_MARKER.length());
+    String[] lineData = lineContent.split(LCOV_DELIMITER, -1);
+    if (lineData.length != 4) {
+      logger.log(Level.WARNING, "Tracefile contains invalid BRDA line " + line);
+      return false;
+    }
+    for (String data : lineData) {
+      if (data.isEmpty()) {
+        logger.log(Level.WARNING, "Tracefile contains invalid BRDA line " + line);
+        return false;
+      }
+    }
+    try {
+      int lineNumber = Integer.parseInt(lineData[0]);
+      int blockNumber = Integer.parseInt(lineData[1]);
+      int branchNumber = Integer.parseInt(lineData[2]);
+      String taken = lineData[3];
+
+      boolean wasExecuted = false;
+      int executionCount = 0;
+      if (taken.equals(TAKEN)) {
+        executionCount = Integer.parseInt(taken);
+        wasExecuted = true;
+      }
+      BranchCoverage branchCoverage =
+          BranchCoverage.create(lineNumber, blockNumber, branchNumber, wasExecuted, executionCount);
+
+      currentSourceFileCoverage.addBranch(lineNumber, branchCoverage);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains an invalid number BRDA line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // BRF:<number of branches found>
+  private boolean parseBRFLine(String line) {
+    String lineContent = line.substring(BRF_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid BRF line " + line);
+      return false;
+    }
+    try {
+      int nrBranchesFound = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrBranchesFound(nrBranchesFound);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING,
+          "Tracefile contains invalid number of branches in BRDA line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // BRH:<number of branches hit>
+  private boolean parseBRHLine(String line) {
+    String lineContent = line.substring(BRH_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid BRH line " + line);
+      return false;
+    }
+    try {
+      int nrBranchesHit = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrBranchesHit(nrBranchesHit);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING,
+          "Tracefile contains invalid number of branches hit in BRH line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // DA:<line number>,<execution count>,[,<checksum>]
+  private boolean parseDALine(String line) {
+    String lineContent = line.substring(DA_MARKER.length());
+    String[] lineData = lineContent.split(LCOV_DELIMITER, -1);
+    if (lineData.length != 2 && lineData.length != 3) {
+      logger.log(Level.WARNING, "Tracefile contains invalid DA line " + line);
+      return false;
+    }
+    for (String data : lineData) {
+      if (data.isEmpty()) {
+        logger.log(Level.WARNING, "Tracefile contains invalid DA line " + line);
+        return false;
+      }
+    }
+    try {
+      int lineNumber = Integer.parseInt(lineData[0]);
+      int executionCount = Integer.parseInt(lineData[1]);
+      String checkSum = null;
+      if (lineData.length == 3) {
+        checkSum = lineData[2];
+      }
+      LineCoverage lineCoverage =
+          LineCoverage.create(lineNumber, executionCount, checkSum);
+      currentSourceFileCoverage.addLine(
+          lineNumber, lineCoverage);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains an invalid number on DA line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // LH:<nr of lines with non-zero exec count>
+  private boolean parseLHLine(String line) {
+    String lineContent = line.substring(LH_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid LHL line " + line);
+      return false;
+    }
+    try {
+      int nrLines = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrOfLinesWithNonZeroExecution(nrLines);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains an invalid number on LHL line " + line);
+      return false;
+    }
+    return true;
+  }
+
+  // LF:<number of instrumented lines>
+  private boolean parseLFLine(String line) {
+    String lineContent = line.substring(LF_MARKER.length());
+    if (lineContent.isEmpty()) {
+      logger.log(Level.WARNING, "Tracefile contains invalid LF line " + line);
+      return false;
+    }
+    try {
+      int nrLines = Integer.parseInt(lineContent);
+      currentSourceFileCoverage.nrOfInstrumentedLines(nrLines);
+    } catch (NumberFormatException e) {
+      logger.log(Level.WARNING, "Tracefile contains an invalid number on LF line " + line);
+      return false;
+    }
+    return true;
+  }
+}
+
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovPrinter.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovPrinter.java
new file mode 100644
index 0000000..59737b99
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LcovPrinter.java
@@ -0,0 +1,229 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.util.Map.Entry;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Prints coverage data stored in a collection of {@link SourceFileCoverage} in a
+ * <a href="http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php"> lcov tracefile format</a>
+ */
+class LcovPrinter {
+  private static final Logger logger = Logger.getLogger(LcovPrinter.class.getName());
+  private final BufferedWriter bufferedWriter;
+
+  private LcovPrinter(BufferedWriter bufferedWriter) {
+    this.bufferedWriter = bufferedWriter;
+  }
+
+  static boolean print(OutputStream outputStream, Coverage coverage) {
+    BufferedWriter bufferedWriter;
+    try (Writer fileWriter = new OutputStreamWriter(outputStream, UTF_8)) {
+      bufferedWriter = new BufferedWriter(fileWriter);
+      LcovPrinter lcovPrinter = new LcovPrinter(bufferedWriter);
+      lcovPrinter.print(coverage);
+      bufferedWriter.close();
+    } catch (IOException exception) {
+      logger.log(Level.SEVERE, "Could not write to output file.");
+      return false;
+    }
+    return true;
+  }
+
+  private boolean print(Coverage coverage) {
+    try {
+      for (SourceFileCoverage sourceFile : coverage.getAllSourceFiles()) {
+        print(sourceFile);
+      }
+    } catch (IOException exception) {
+      logger.log(Level.SEVERE, "Could not write to output file.");
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Prints the given source data in an lcov tracefile format.
+   *
+   * Assumes the file is opened and closed outside of this method.
+   */
+  @VisibleForTesting
+  void print(SourceFileCoverage sourceFile) throws IOException {
+    printSFLine(sourceFile);
+    printFNLines(sourceFile);
+    printFNDALines(sourceFile);
+    printFNFLine(sourceFile);
+    printFNHLine(sourceFile);
+    printBRDALines(sourceFile);
+    printBALines(sourceFile);
+    printBRFLine(sourceFile);
+    printBRHLine(sourceFile);
+    printDALines(sourceFile);
+    printLHLine(sourceFile);
+    printLFLine(sourceFile);
+    printEndOfRecordLine();
+  }
+
+  // SF:<absolute path to the source file>
+  private void printSFLine(SourceFileCoverage sourceFile) throws IOException {
+    bufferedWriter.write(LcovConstants.SF_MARKER);
+    bufferedWriter.write(sourceFile.sourceFileName());
+    bufferedWriter.newLine();
+  }
+
+  // FN:<line number of function start>,<function name>
+  private void printFNLines(SourceFileCoverage sourceFile) throws IOException {
+    for (Entry<String, Integer> entry :
+        sourceFile.getAllLineNumbers()) {
+      bufferedWriter.write(LcovConstants.FN_MARKER);
+      bufferedWriter.write(Integer.toString(entry.getValue())); // line number of function start
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      bufferedWriter.write(entry.getKey()); // function name
+      bufferedWriter.newLine();
+    }
+  }
+
+  // FNDA:<execution count>,<function name>
+  private void printFNDALines(SourceFileCoverage sourceFile) throws IOException {
+    for (Entry<String, Integer> entry :
+        sourceFile.getAllExecutionCount()) {
+      bufferedWriter.write(LcovConstants.FNDA_MARKER);
+      bufferedWriter.write(Integer.toString(entry.getValue())); // execution count
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      bufferedWriter.write(entry.getKey()); // function name
+      bufferedWriter.newLine();
+    }
+  }
+
+  // FNF:<number of functions found>
+  private void printFNFLine(SourceFileCoverage sourceFile) throws IOException {
+    bufferedWriter.write(LcovConstants.FNF_MARKER);
+    bufferedWriter.write(Integer.toString(sourceFile.nrFunctionsFound()));
+    bufferedWriter.newLine();
+  }
+
+  // FNH:<number of functions hit>
+  private void printFNHLine(SourceFileCoverage sourceFile) throws IOException {
+    bufferedWriter.write(LcovConstants.FNH_MARKER);
+    bufferedWriter.write(Integer.toString(sourceFile.nrFunctionsHit()));
+    bufferedWriter.newLine();
+  }
+
+  // BRDA:<line number>,<block number>,<branch number>,<taken>
+  private void printBRDALines(SourceFileCoverage sourceFile) throws IOException {
+    for (BranchCoverage branch : sourceFile.getAllBranches()) {
+      if (branch.blockNumber() == -1 || branch.branchNumber() == -1) {
+        // We skip printing this as a BRDA line and print it later as a BA line.
+        continue;
+      }
+      bufferedWriter.write(LcovConstants.BRDA_MARKER);
+      bufferedWriter.write(Integer.toString(branch.lineNumber()));
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      bufferedWriter.write(Integer.toString(branch.blockNumber()));
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      bufferedWriter.write(Integer.toString(branch.branchNumber()));
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      if (branch.wasExecuted()) {
+        bufferedWriter.write(Integer.toString(branch.nrOfExecutions()));
+      } else {
+        bufferedWriter.write(LcovConstants.TAKEN);
+      }
+      bufferedWriter.newLine();
+    }
+  }
+
+  // BA:<line number>,<taken>
+  private void printBALines(SourceFileCoverage sourceFile) throws IOException {
+    for (BranchCoverage branch : sourceFile.getAllBranches()) {
+      if (branch.branchNumber() != -1 && branch.blockNumber() != -1) {
+        // This branch was already printed with more information as a BRDA line.
+        continue;
+      }
+      bufferedWriter.write(LcovConstants.BA_MARKER);
+      bufferedWriter.write(Integer.toString(branch.lineNumber()));
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      // 0 = branch was not executed
+      // 1 = branch was executed but not taken
+      // 2 = branch was executed and taken
+      bufferedWriter.write(branch.wasExecuted() ? "2" : "0");
+      bufferedWriter.newLine();
+    }
+  }
+
+  // BRF:<number of branches found>
+  private void printBRFLine(SourceFileCoverage sourceFile) throws IOException {
+    if (sourceFile.nrBranchesFound() > 0) {
+      bufferedWriter.write(LcovConstants.BRF_MARKER);
+      bufferedWriter.write(Integer.toString(sourceFile.nrBranchesFound()));
+      bufferedWriter.newLine();
+    }
+  }
+
+  // BRH:<number of branches hit>
+  private void printBRHLine(SourceFileCoverage sourceFile) throws IOException {
+    // Only print if there were any branches found.
+    if (sourceFile.nrBranchesFound() > 0) {
+      bufferedWriter.write(LcovConstants.BRH_MARKER);
+      bufferedWriter.write(Integer.toString(sourceFile.nrBranchesHit()));
+      bufferedWriter.newLine();
+    }
+  }
+
+  // DA:<line number>,<execution count>[,<checksum>]
+  private void printDALines(SourceFileCoverage sourceFile) throws IOException {
+    for (LineCoverage lineExecution :
+        sourceFile.getAllLineExecution()) {
+      bufferedWriter.write(LcovConstants.DA_MARKER);
+      bufferedWriter.write(Integer.toString(lineExecution.lineNumber()));
+      bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+      bufferedWriter.write(Integer.toString(lineExecution.executionCount()));
+      if (lineExecution.checksum() != null) {
+        bufferedWriter.write(LcovConstants.LCOV_DELIMITER);
+        bufferedWriter.write(lineExecution.checksum());
+      }
+      bufferedWriter.newLine();
+    }
+  }
+
+  // LH:<number of lines with a non-zero execution count>
+  private void printLHLine(SourceFileCoverage sourceFile) throws IOException {
+    bufferedWriter.write(LcovConstants.LH_MARKER);
+    bufferedWriter.write(Integer.toString(sourceFile.nrOfLinesWithNonZeroExecution()));
+    bufferedWriter.newLine();
+  }
+
+  // LF:<number of instrumented lines>
+  private void printLFLine(SourceFileCoverage sourceFile) throws IOException {
+    bufferedWriter.write(LcovConstants.LF_MARKER);
+    bufferedWriter.write(Integer.toString(sourceFile.nrOfInstrumentedLines()));
+    bufferedWriter.newLine();
+  }
+
+  // end_of_record
+  private void printEndOfRecordLine() throws IOException {
+    bufferedWriter.write(LcovConstants.END_OF_RECORD_MARKER);
+    bufferedWriter.newLine();
+  }
+}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LineCoverage.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LineCoverage.java
new file mode 100644
index 0000000..4e163e2
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/LineCoverage.java
@@ -0,0 +1,46 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import com.google.auto.value.AutoValue;
+import javax.annotation.Nullable;
+
+/**
+ * Stores line execution coverage information.
+ */
+@AutoValue
+abstract class LineCoverage {
+  static LineCoverage create(int lineNumber, int executionCount, String checksum) {
+    return new AutoValue_LineCoverage(lineNumber, executionCount, checksum);
+  }
+
+  static LineCoverage merge(
+      LineCoverage first, LineCoverage second) {
+    assert first.lineNumber() == second.lineNumber();
+    assert (first.checksum() == null && second.checksum() == null)
+        || (first.checksum().equals(second.checksum()));
+    return create(
+        first.lineNumber(),
+        first.executionCount() + second.executionCount(),
+        first.checksum()
+    );
+  }
+
+  abstract int lineNumber();
+  abstract int executionCount();
+  // The current geninfo implementation uses an MD5 hash as checksumming algorithm.
+  @Nullable
+  abstract String checksum(); // optional
+}
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Main.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Main.java
index e5bf197..5bac98e 100644
--- a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Main.java
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/Main.java
@@ -14,10 +14,24 @@
 
 package com.google.devtools.lcovmerger;
 
+import static com.google.devtools.lcovmerger.LcovConstants.TRACEFILE_EXTENSION;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * Command line utility to convert raw coverage files to lcov (text) format.
@@ -34,9 +48,52 @@
       System.exit(1);
     }
 
-    LcovMerger lcovMerger = new LcovMerger(flags.get("coverage_dir"), flags.get("output_file"));
-    boolean success = lcovMerger.merge();
-    System.exit(success ? 0 : 1);
+    List<File> lcovTracefiles = getLcovTracefiles(flags.get("coverage_dir"));
+    if (lcovTracefiles.isEmpty()) {
+      logger.log(Level.SEVERE, "No lcov file found.");
+      System.exit(1);
+    }
+    Coverage coverage = new Coverage();
+    for (File tracefile : lcovTracefiles) {
+      try {
+        List<SourceFileCoverage> sourceFilesCoverage =
+            LcovParser.parse(new FileInputStream(tracefile));
+        for (SourceFileCoverage sourceFileCoverage : sourceFilesCoverage) {
+          coverage.add(sourceFileCoverage);
+        }
+      } catch (IOException e) {
+        logger.log(Level.SEVERE, "Tracefile " + tracefile.getAbsolutePath() + " was deleted");
+        System.exit(1);
+      }
+    }
+    int exitStatus = 0;
+    String outputFile = flags.get("output_file");
+    try {
+      File coverageFile = new File(outputFile);
+      LcovPrinter.print(new FileOutputStream(coverageFile), coverage);
+    } catch (IOException e) {
+      logger.log(Level.SEVERE, "Could not write to output file " + outputFile);
+      exitStatus = 1;
+    }
+    System.exit(exitStatus);
+  }
+
+  /**
+   * Returns a list of all the files with a “.dat” extension found recursively under the given
+   * directory.
+   */
+  @VisibleForTesting
+  static List<File> getLcovTracefiles(String coverageDir) {
+    List<File> datFiles = new ArrayList<>();
+    try (Stream<Path> stream = Files.walk(Paths.get(coverageDir))) {
+      datFiles = stream.filter(p -> p.toString().endsWith(TRACEFILE_EXTENSION))
+          .map(path -> path.toFile())
+          .collect(Collectors.toList());
+    } catch (IOException ex) {
+      logger.log(Level.SEVERE, "error reading folder " + coverageDir + ": " + ex.getMessage());
+    }
+
+    return datFiles;
   }
 
   /**
diff --git a/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/SourceFileCoverage.java b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/SourceFileCoverage.java
new file mode 100644
index 0000000..3e293a7
--- /dev/null
+++ b/tools/test/LcovMerger/java/com/google/devtools/lcovmerger/SourceFileCoverage.java
@@ -0,0 +1,298 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/*
+ * Stores coverage information for a specific source file.
+ */
+class SourceFileCoverage {
+
+  private final String sourceFileName;
+  private final TreeMap<String, Integer> lineNumbers; // function name to line numbers
+  private final TreeMap<String, Integer> functionsExecution; // function name to execution count
+  private final TreeMap<Integer, BranchCoverage> branches; // line number to branch
+  private final TreeMap<Integer, LineCoverage> lines; // line number to line execution
+
+  private int nrFunctionsFound;
+  private int nrFunctionsHit;
+  private int nrBranchesFound;
+  private int nrBranchesHit;
+  private int nrOfLinesWithNonZeroExecution;
+  private int nrOfInstrumentedLines;
+
+  SourceFileCoverage(String sourcefile) {
+    this.sourceFileName = sourcefile;
+    this.functionsExecution = new TreeMap<>();
+    this.lineNumbers = new TreeMap<>();
+    this.lines = new TreeMap<>();
+    this.branches = new TreeMap<>();
+  }
+
+  SourceFileCoverage(SourceFileCoverage other) {
+    this.sourceFileName = other.sourceFileName;
+
+    this.functionsExecution = new TreeMap<>();
+    this.lineNumbers = new TreeMap<>();
+    this.lines = new TreeMap<>();
+    this.branches = new TreeMap<>();
+
+    this.lineNumbers.putAll(other.lineNumbers);
+    this.functionsExecution.putAll(other.functionsExecution);
+    this.branches.putAll(other.branches);
+    this.lines.putAll(other.lines);
+
+    this.nrFunctionsFound = other.nrFunctionsFound;
+    this.nrFunctionsHit = other.nrFunctionsHit;
+    this.nrBranchesFound = other.nrBranchesFound;
+    this.nrBranchesHit = other.nrBranchesHit;
+    this.nrOfLinesWithNonZeroExecution = other.nrOfLinesWithNonZeroExecution;
+    this.nrOfInstrumentedLines = other.nrOfInstrumentedLines;
+  }
+
+  /*
+   * Returns the merged functions found in the two given {@code SourceFileCoverage}s.
+   */
+  @VisibleForTesting
+  static TreeMap<String, Integer> mergeLineNumbers(SourceFileCoverage s1, SourceFileCoverage s2) {
+    TreeMap<String, Integer> merged = new TreeMap<>();
+    merged.putAll(s1.lineNumbers);
+    merged.putAll(s2.lineNumbers);
+    return merged;
+  }
+
+  /*
+   *
+   * Returns the merged execution count found in the two given {@code SourceFileCoverage}s.
+   */
+  @VisibleForTesting
+  static TreeMap<String, Integer> mergeFunctionsExecution(
+      SourceFileCoverage s1, SourceFileCoverage s2) {
+    return Stream.of(
+            s1.functionsExecution, s2.functionsExecution)
+            .map(Map::entrySet)
+            .flatMap(Collection::stream)
+            .collect(
+                Collectors.toMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    Integer::sum,
+                    TreeMap::new
+                ));
+  }
+
+  /*
+   *
+   * Returns the merged branches found in the two given {@code SourceFileCoverage}s.
+   */
+  @VisibleForTesting
+  static TreeMap<Integer, BranchCoverage> mergeBranches(
+      SourceFileCoverage s1, SourceFileCoverage s2) {
+    return Stream.of(s1.branches, s2.branches)
+        .map(Map::entrySet)
+        .flatMap(Collection::stream)
+        .collect(
+            Collectors.toMap(
+              Map.Entry::getKey,
+              Map.Entry::getValue,
+              BranchCoverage::merge,
+              TreeMap::new
+            )
+        );
+  }
+
+  static int getNumberOfBranchesHit(SourceFileCoverage sourceFileCoverage) {
+    return (int) sourceFileCoverage.branches.entrySet().stream()
+        .filter(branch -> branch.getValue().wasExecuted())
+        .count();
+  }
+
+  /*
+   * Returns the merged line execution found in the two given {@code SourceFileCoverage}s.
+   */
+  @VisibleForTesting
+  static TreeMap<Integer, LineCoverage> mergeLines(
+      SourceFileCoverage s1, SourceFileCoverage s2) {
+    return Stream.of(s1.lines, s2.lines)
+            .map(Map::entrySet)
+            .flatMap(Collection::stream)
+            .collect(
+                Collectors.toMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    LineCoverage::merge,
+                    TreeMap::new
+                )
+            );
+  }
+
+  private static int getNumberOfExecutedLines(SourceFileCoverage sourceFileCoverage) {
+    return (int) sourceFileCoverage.lines.entrySet().stream()
+        .filter(line -> line.getValue().executionCount() > 0)
+        .count();
+  }
+
+  /**
+   * Merges all the fields of {@code other} with the current {@link SourceFileCoverage} into a new
+   * {@link SourceFileCoverage}
+   *
+   * Assumes both the current and the given {@link SourceFileCoverage} have the same
+   * {@code sourceFileName}.
+   *
+   * @return a new {@link SourceFileCoverage} that contains the merged coverage.
+   */
+  static SourceFileCoverage merge(SourceFileCoverage source1, SourceFileCoverage source2) {
+    assert source1.sourceFileName.equals(source2.sourceFileName);
+    SourceFileCoverage merged = new SourceFileCoverage(source2.sourceFileName);
+
+    merged.addAllLineNumbers(mergeLineNumbers(source1, source2));
+    merged.addAllFunctionsExecution(mergeFunctionsExecution(source1, source2));
+    merged.addAllBranches(mergeBranches(source1, source2));
+    merged.addAllLines(mergeLines(source1, source2));
+
+    merged.nrBranchesHit(getNumberOfBranchesHit(merged));
+    merged.nrOfLinesWithNonZeroExecution(getNumberOfExecutedLines(merged));
+    merged.nrFunctionsFound(merged.lineNumbers.size());
+    merged.nrFunctionsHit(merged.functionsExecution.size());
+    merged.nrBranchesFound(merged.branches.size());
+    merged.nrOfInstrumentedLines(merged.lines.size());
+    return merged;
+  }
+
+  String sourceFileName() {
+    return sourceFileName;
+  }
+
+  int nrFunctionsFound() {
+    return nrFunctionsFound;
+  }
+
+  void nrFunctionsFound(int nrFunctionsFound) {
+    this.nrFunctionsFound = nrFunctionsFound;
+  }
+
+  int nrFunctionsHit() {
+    return nrFunctionsHit;
+  }
+
+  void nrFunctionsHit(int nrFunctionsHit) {
+    this.nrFunctionsHit = nrFunctionsHit;
+  }
+
+  int nrBranchesFound() {
+    return nrBranchesFound;
+  }
+
+  void nrBranchesFound(int nrBranchesFound) {
+    this.nrBranchesFound = nrBranchesFound;
+  }
+
+  int nrBranchesHit() {
+    return nrBranchesHit;
+  }
+
+  void nrBranchesHit(int nrBranchesHit) {
+    this.nrBranchesHit = nrBranchesHit;
+  }
+
+  int nrOfLinesWithNonZeroExecution() {
+    return nrOfLinesWithNonZeroExecution;
+  }
+
+  void nrOfLinesWithNonZeroExecution(int nrOfLinesWithNonZeroExecution) {
+    this.nrOfLinesWithNonZeroExecution = nrOfLinesWithNonZeroExecution;
+  }
+
+  int nrOfInstrumentedLines() {
+    return nrOfInstrumentedLines;
+  }
+
+  void nrOfInstrumentedLines(int nrOfInstrumentedLines) {
+    this.nrOfInstrumentedLines = nrOfInstrumentedLines;
+  }
+
+  Collection<LineCoverage> getAllLineExecution() {
+    return lines.values();
+  }
+
+  @VisibleForTesting
+  TreeMap<String, Integer> getLineNumbers() {
+    return lineNumbers;
+  }
+
+
+  Set<Entry<String, Integer>> getAllLineNumbers() {
+    return lineNumbers.entrySet();
+  }
+
+  @VisibleForTesting
+  TreeMap<String, Integer> getFunctionsExecution() {
+    return functionsExecution;
+  }
+
+  Set<Entry<String, Integer>> getAllExecutionCount() {
+    return functionsExecution.entrySet();
+  }
+
+  Collection<BranchCoverage> getAllBranches() {
+    return branches.values();
+  }
+
+  @VisibleForTesting
+  Map<Integer, LineCoverage> getLines() {
+    return lines;
+  }
+
+  void addLineNumber(String functionName, Integer lineNumber) {
+    this.lineNumbers.put(functionName, lineNumber);
+  }
+
+  void addAllLineNumbers(TreeMap<String, Integer> lineNumber) {
+    this.lineNumbers.putAll(lineNumber);
+  }
+
+  void addFunctionExecution(String functionName, Integer executionCount) {
+    this.functionsExecution.put(functionName, executionCount);
+  }
+
+  void addAllFunctionsExecution(TreeMap<String, Integer> functionsExecution) {
+    this.functionsExecution.putAll(functionsExecution);
+  }
+
+  void addBranch(Integer lineNumber, BranchCoverage branch) {
+    this.branches.put(lineNumber, branch);
+  }
+
+  void addAllBranches(TreeMap<Integer, BranchCoverage> branches) {
+    this.branches.putAll(branches);
+  }
+
+  void addLine(Integer lineNumber, LineCoverage line) {
+    this.lines.put(lineNumber, line);
+  }
+
+  void addAllLines(TreeMap<Integer, LineCoverage> lines) {
+    this.lines.putAll(lines);
+  }
+}
+
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BUILD b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BUILD
new file mode 100644
index 0000000..daf81b5
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BUILD
@@ -0,0 +1,108 @@
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache 2.0
+
+java_test(
+    name = "BranchCoverageTest",
+    srcs = ["BranchCoverageTest.java"],
+    deps = [
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:BranchCoverage",
+    ],
+)
+
+java_test(
+    name = "LineCoverageTest",
+    srcs = ["LineCoverageTest.java"],
+    deps = [
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LineCoverage",
+    ],
+)
+
+java_test(
+    name = "SourceFileCoverageTest",
+    srcs = ["SourceFileCoverageTest.java"],
+    deps = [
+        ":LcovMergerTestUtils",
+        ":LineCoverageTest",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LineCoverage",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:SourceFileCoverage",
+    ],
+)
+
+java_test(
+    name = "LcovPrinterTest",
+    srcs = ["LcovPrinterTest.java"],
+    deps = [
+        ":LcovMergerTestUtils",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:Coverage",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LcovConstants",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LcovPrinter",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:SourceFileCoverage",
+    ],
+)
+
+java_test(
+    name = "LcovParserTest",
+    srcs = ["LcovParserTest.java"],
+    deps = [
+        ":LcovMergerTestUtils",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:Coverage",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LcovConstants",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LcovParser",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:SourceFileCoverage",
+    ],
+)
+
+java_test(
+    name = "CoverageTest",
+    srcs = ["CoverageTest.java"],
+    deps = [
+        ":LcovMergerTestUtils",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:Coverage",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:SourceFileCoverage",
+    ],
+)
+
+java_test(
+    name = "MainTest",
+    srcs = ["MainTest.java"],
+    deps = [
+        ":LcovMergerTestUtils",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:MainLibrary",
+    ],
+)
+
+java_library(
+    name = "LcovMergerTestUtils",
+    testonly = 1,
+    srcs = ["LcovMergerTestUtils.java"],
+    deps = [
+        "//third_party:guava",
+        "//third_party:truth",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:LineCoverage",
+        "//tools/test/LcovMerger/java/com/google/devtools/lcovmerger:SourceFileCoverage",
+    ],
+)
+
+filegroup(
+    name = "srcs",
+    srcs = glob(["**"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BranchCoverageTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BranchCoverageTest.java
new file mode 100644
index 0000000..bd527e5
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/BranchCoverageTest.java
@@ -0,0 +1,156 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@BranchCoverageData}.
+ */
+@RunWith(JUnit4.class)
+public class BranchCoverageTest {
+
+  private static final int BRANCH1_LINE_NR = 10;
+  private static final int BRANCH1_BLOCK_NR = 3;
+  private static final int BRANCH1_BRANCH_NR = 2;
+  private static final boolean BRANCH1_WAS_EXECUTED = false;
+  private static final int BRANCH1_NR_EXECUTIONS = 0;
+
+  private static final boolean BRANCH1_OTHER_TRACEFILE_WAS_EXECUTED = true;
+  private static final int BRANCH1_OTHER_TRACEFILE_NR_EXECUTIONS = 5;
+
+  private static final int BRANCH2_LINE_NR = 20;
+  private static final int BRANCH2_BLOCK_NR = 7;
+  private static final int BRANCH2_BRANCH_NR = 2;
+  private static final boolean BRANCH2_WAS_EXECUTED = false;
+  private static final int BRANCH2_NR_EXECUTIONS = 0;
+
+  private static final boolean BRANCH2_OTHER_TRACEFILE_WAS_EXECUTED = false;
+  private static final int BRANCH2_OTHER_TRACEFILE_NR_EXECUTIONS = 0;
+
+  static final BranchCoverage getBranch1CoverageData() {
+    return BranchCoverage.create(
+        BRANCH1_LINE_NR,
+        BRANCH1_BLOCK_NR,
+        BRANCH1_BRANCH_NR,
+        BRANCH1_WAS_EXECUTED,
+        BRANCH1_NR_EXECUTIONS
+    );
+  }
+
+  static final BranchCoverage getBranch2CoverageData() {
+    return BranchCoverage.create(
+        BRANCH2_LINE_NR,
+        BRANCH2_BLOCK_NR,
+        BRANCH2_BRANCH_NR,
+        BRANCH2_WAS_EXECUTED,
+        BRANCH2_NR_EXECUTIONS
+    );
+  }
+
+  static final BranchCoverage getBranch1OtherTracefileCoverageData() {
+    return BranchCoverage.create(
+        BRANCH1_LINE_NR,
+        BRANCH1_BLOCK_NR,
+        BRANCH1_BRANCH_NR,
+        BRANCH1_OTHER_TRACEFILE_WAS_EXECUTED,
+        BRANCH1_OTHER_TRACEFILE_NR_EXECUTIONS
+    );
+  }
+
+  static final BranchCoverage getBranch2OtherTracefileCoverageData() {
+    return BranchCoverage.create(
+        BRANCH2_LINE_NR,
+        BRANCH2_BLOCK_NR,
+        BRANCH2_BRANCH_NR,
+        BRANCH2_OTHER_TRACEFILE_WAS_EXECUTED,
+        BRANCH2_OTHER_TRACEFILE_NR_EXECUTIONS
+    );
+  }
+
+  @Test
+  public void testMergeBranch1() {
+    BranchCoverage branch1 = getBranch1CoverageData();
+    BranchCoverage branch1OtherTracefile = getBranch1OtherTracefileCoverageData();
+    BranchCoverage merged = BranchCoverage.merge(branch1, branch1OtherTracefile);
+    assertThat(merged.lineNumber()).isEqualTo(branch1.lineNumber());
+    assertThat(merged.blockNumber()).isEqualTo(branch1.blockNumber());
+    assertThat(merged.branchNumber()).isEqualTo(branch1.branchNumber());
+    assertThat(merged.wasExecuted()).isTrue();
+    assertThat(merged.nrOfExecutions()).isEqualTo(
+        branch1.nrOfExecutions() + branch1OtherTracefile.nrOfExecutions());
+  }
+
+  @Test
+  public void testMergeBranch2() {
+    BranchCoverage branch2 = getBranch2CoverageData();
+    BranchCoverage branch2OtherTracefile = getBranch2OtherTracefileCoverageData();
+    BranchCoverage merged = BranchCoverage.merge(branch2, branch2OtherTracefile);
+    assertThat(merged.lineNumber()).isEqualTo(branch2.lineNumber());
+    assertThat(merged.blockNumber()).isEqualTo(branch2.blockNumber());
+    assertThat(merged.branchNumber()).isEqualTo(branch2.branchNumber());
+    assertThat(merged.wasExecuted()).isFalse();
+    assertThat(merged.nrOfExecutions()).isEqualTo(0);
+  }
+
+  @Test
+  public void testMergeBranch1Branch2AssertationError() {
+    BranchCoverage branch1 = getBranch1CoverageData();
+    BranchCoverage branch2 = getBranch2CoverageData();
+    try {
+      BranchCoverage.merge(branch1, branch2);
+    } catch (AssertionError er) {
+      return;
+    }
+    fail();
+  }
+
+  @Test
+  public void testcreateBranchCoverageDataInvalidWasExecutedTrue() {
+    try {
+      BranchCoverage.create(
+          BRANCH1_LINE_NR,
+          BRANCH1_BLOCK_NR,
+          BRANCH1_BRANCH_NR,
+          true,
+          0
+      );
+    } catch (AssertionError er) {
+      return;
+    }
+    fail();
+  }
+
+  @Test
+  public void testcreateBranchCoverageDataInvalidWasExecutedFalse() {
+    try {
+      BranchCoverage.create(
+          BRANCH1_LINE_NR,
+          BRANCH1_BLOCK_NR,
+          BRANCH1_BRANCH_NR,
+          false,
+          10
+      );
+    } catch (AssertionError er) {
+      return;
+    }
+    fail();
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/CoverageTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/CoverageTest.java
new file mode 100644
index 0000000..724a209
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/CoverageTest.java
@@ -0,0 +1,81 @@
+// Copyright 2016 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertMergedSourceFile;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertTracefile1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createLinesExecution1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createLinesExecution2;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createSourceFile1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createSourceFile2;
+
+import com.google.common.collect.Iterables;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Test for LcovMerger.
+ */
+@RunWith(JUnit4.class)
+public class CoverageTest {
+
+  private Coverage coverage;
+
+  @Before
+  public void initializeCoverage() {
+    coverage = new Coverage();
+  }
+
+  @Test
+  public void testOneTracefile() {
+    SourceFileCoverage sourceFileCoverage =
+        createSourceFile1(createLinesExecution1());
+    coverage.add(sourceFileCoverage);
+    assertThat(coverage.getAllSourceFiles()).hasSize(1);
+    assertTracefile1(Iterables.get(coverage.getAllSourceFiles(), 0));
+  }
+
+  @Test
+  public void testTwoOverlappingTracefiles() {
+    int[] linesExecution1 = createLinesExecution1();
+    int[] linesExecution2 = createLinesExecution2();
+    SourceFileCoverage sourceFileCoverage1 = createSourceFile1(linesExecution1);
+    SourceFileCoverage sourceFileCoverage2 = createSourceFile2(linesExecution2);
+
+    coverage.add(sourceFileCoverage1);
+    coverage.add(sourceFileCoverage2);
+
+    assertThat(coverage.getAllSourceFiles()).hasSize(1);
+    SourceFileCoverage merged = Iterables.get(coverage.getAllSourceFiles(), 0);
+    assertMergedSourceFile(merged, linesExecution1, linesExecution2);
+  }
+
+  @Test
+  public void testTwoTracefiles() {
+    SourceFileCoverage sourceFileCoverage1 =
+        createSourceFile1(createLinesExecution1());
+    SourceFileCoverage sourceFileCoverage2 = createSourceFile1(
+        "SOME_OTHER_FILENAME", createLinesExecution1());
+
+    coverage.add(sourceFileCoverage1);
+    coverage.add(sourceFileCoverage2);
+    assertThat(coverage.getAllSourceFiles()).hasSize(2);
+    assertTracefile1(Iterables.get(coverage.getAllSourceFiles(), 0));
+    assertTracefile1(Iterables.get(coverage.getAllSourceFiles(), 1));
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTest.java
deleted file mode 100644
index 8460b7e..0000000
--- a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2016 The Bazel Authors. All rights reserved.
-//
-// 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.devtools.lcovmerger;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.devtools.build.lib.testutil.Scratch;
-import com.google.devtools.build.lib.vfs.FileSystemUtils;
-import com.google.devtools.build.lib.vfs.util.FileSystems;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-/**
- * Test for LcovMerger.
- */
-@RunWith(JUnit4.class)
-public class LcovMergerTest {
-
-  private final Scratch scratch =
-      new Scratch(FileSystemUtils.getWorkingDirectory(FileSystems.getJavaIoFileSystem()));
-
-  @Test
-  public void testZeroDatFile() throws IOException {
-    scratch.dir("dir0");
-
-    File merged = new File("dir0Merged.dat");
-    File dir = new File("dir0");
-    LcovMerger merger = new LcovMerger(dir.getAbsolutePath(), merged.getAbsolutePath());
-    boolean success = merger.merge();
-    assertThat(success).isFalse();
-
-    assertThat(merged.exists()).isFalse();
-  }
-
-  @Test
-  public void testOneDatFile() throws IOException {
-    String content = "This is an lcov file.";
-    scratch.file("dir1/jvcov.dat", content);
-
-    File merged = new File("dir1Merged.dat");
-    File dir = new File("dir1");
-    LcovMerger merger = new LcovMerger(dir.getAbsolutePath(), merged.getAbsolutePath());
-    boolean success = merger.merge();
-    assertThat(success).isTrue();
-
-    assertThat(merged.exists()).isTrue();
-    String readContent = new String(Files.readAllBytes(merged.toPath())).trim();
-    assertThat(readContent).isEqualTo(content);
-  }
-
-  @Test
-  public void testTwoDatFiles() throws IOException {
-    scratch.file("dir2/jvcov1.dat", "This is an lcov file.");
-    scratch.file("dir2/jvcov2.dat", "This is another lcov file.");
-
-    File merged = new File("dir2Merged.dat");
-    File dir = new File("dir2");
-    LcovMerger merger = new LcovMerger(dir.getAbsolutePath(), merged.getAbsolutePath());
-    boolean success = merger.merge();
-    assertThat(success).isFalse();
-
-    assertThat(merged.exists()).isFalse();
-  }
-}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTestUtils.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTestUtils.java
new file mode 100644
index 0000000..abaed31
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovMergerTestUtils.java
@@ -0,0 +1,424 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Helper class for creating and parsing lcov tracefiles and the necessary data structured used by
+ * {@code LcovMerger}.
+ *
+ * The tests are floating around 2 main tracefiles (numbered with 1 and 2 throughout the tests code
+ * base). The tracefiles refer the same source file with different coverage data, making them good
+ * candidates for being merged.
+ *
+ * There are multiple static variables defined for information extracted from each of
+ * tracefile{1,2} (e.g. the number of lines found, the number of functions hit, lines execution).
+ *
+ * Additionally, the 2 tracefiles may be used in tests multiple times with different source
+ * filenames so they could be considered as different entries in the merged tracefile.
+ */
+public class LcovMergerTestUtils {
+
+  // The content of tracefile1.
+  static final ImmutableList<String> TRACEFILE1 = ImmutableList.of(
+      "SF:SOURCE_FILENAME",
+      "FN:10,file1-func1",
+      "FN:20,file1-func2",
+      "FN:25,file1-func3",
+      "FNDA:3,file1-func1",
+      "FNDA:5,file1-func2",
+      "FNDA:0,file1-func3",
+      "FNF:3",
+      "FNH:2",
+      "DA:10,3",
+      "DA:11,3",
+      "DA:12,30",
+      "DA:13,30",
+      "DA:15,3",
+      "DA:16,0",
+      "DA:17,0",
+      "DA:19,3",
+      "DA:20,5",
+      "DA:21,5",
+      "DA:22,5",
+      "DA:23,5",
+      "DA:25,0",
+      "DA:26,0",
+      "LH:10",
+      "LF:14",
+      "end_of_record"
+  );
+
+  static final ImmutableList<String> TRACEFILE1_DIFFERENT_NAME = ImmutableList.of(
+      "SF:DIFFERENT_SOURCE_FILENAME",
+      "FN:10,file1-func1",
+      "FN:20,file1-func2",
+      "FN:25,file1-func3",
+      "FNDA:3,file1-func1",
+      "FNDA:5,file1-func2",
+      "FNDA:0,file1-func3",
+      "FNF:3",
+      "FNH:2",
+      "DA:10,3",
+      "DA:11,3",
+      "DA:12,30",
+      "DA:13,30",
+      "DA:15,3",
+      "DA:16,0",
+      "DA:17,0",
+      "DA:19,3",
+      "DA:20,5",
+      "DA:21,5",
+      "DA:22,5",
+      "DA:23,5",
+      "DA:25,0",
+      "DA:26,0",
+      "LH:10",
+      "LF:14",
+      "end_of_record"
+  );
+
+  // The content of tracefile2.
+  static final ImmutableList<String> TRACEFILE2 = ImmutableList.of(
+      "SF:SOURCE_FILENAME",
+      "FN:10,file1-func1",
+      "FN:20,file1-func2",
+      "FN:25,file1-func3",
+      "FNDA:2,file1-func1",
+      "FNDA:3,file1-func2",
+      "FNDA:2,file1-func3",
+      "FNF:3",
+      "FNH:3",
+      "DA:10,2",
+      "DA:11,2",
+      "DA:12,20",
+      "DA:13,20",
+      "DA:15,2",
+      "DA:16,2",
+      "DA:17,2",
+      "DA:19,0",
+      "DA:20,3",
+      "DA:21,3",
+      "DA:22,3",
+      "DA:23,3",
+      "DA:25,2",
+      "DA:26,2",
+      "LH:13",
+      "LF:14",
+      "end_of_record"
+  );
+
+  // The content of a tracefile after tracefile1 and tracefile2 were merged together.
+  static final ImmutableList<String> MERGED_TRACEFILE = ImmutableList.of(
+      "SF:SOURCE_FILENAME",
+      "FN:10,file1-func1",
+      "FN:20,file1-func2",
+      "FN:25,file1-func3",
+      "FNDA:5,file1-func1",
+      "FNDA:8,file1-func2",
+      "FNDA:2,file1-func3",
+      "FNF:3",
+      "FNH:3",
+      "DA:10,5",
+      "DA:11,5",
+      "DA:12,50",
+      "DA:13,50",
+      "DA:15,5",
+      "DA:16,2",
+      "DA:17,2",
+      "DA:19,3",
+      "DA:20,8",
+      "DA:21,8",
+      "DA:22,8",
+      "DA:23,8",
+      "DA:25,2",
+      "DA:26,2",
+      "LH:14",
+      "LF:14",
+      "end_of_record"
+  );
+
+  static final String SOURCE_FILENAME = "SOURCE_FILENAME";
+  static final int NR_FUNCTIONS_FOUND = 3;
+  static final int NR_FUNCTIONS_HIT_TRACEFILE1 = 2;
+  static final int NR_FUNCTIONS_HIT_TRACEFILE2 = 3;
+
+  static final String FUNC_1 = "file1-func1";
+  static final int FUNC_1_LINE_NR = 10;
+  static final int FUNC_1_NR_EXECUTED_LINES_TRACEFILE1 = 3;
+  static final int FUNC_1_NR_EXECUTED_LINES_TRACEFILE2 = 2;
+
+  static final String FUNC_2 = "file1-func2";
+  static final int FUNC_2_LINE_NR = 20;
+  static final int FUNC_2_NR_EXECUTED_LINES_TRACEFILE1 = 5;
+  static final int FUNC_2_NR_EXECECUTED_LINES_TRACEFILE2 = 3;
+
+  static final String FUNC_3 = "file1-func3";
+  static final int FUNC_3_LINE_NR = 25;
+  static final int FUNC_3_NR_EXECUTED_LINES_TRACEFILE1 = 0;
+  static final int FUNC_3_NR_EXECUTED_LINES_TRACEFILE2 = 2;
+
+  static final int NR_LINES_FOUND = 14;
+  static final int NR_LINES_HIT_TRACEFILE1 = 10;
+  static final int NR_LINES_HIT_TRACEFILE2 = 13;
+
+  static final int MAX_LINES_IN_FILE = 27;
+
+  static int[] createLinesExecution1() {
+    int[] lineExecutionCountForTracefile = new int[MAX_LINES_IN_FILE];
+    for (int i = 0; i < MAX_LINES_IN_FILE; ++i) {
+      lineExecutionCountForTracefile[i] = -1;       // no corresponding DA line for line i
+    }
+
+    lineExecutionCountForTracefile[10] = 3;         // DA:10,3
+    lineExecutionCountForTracefile[11] = 3;         // DA:11,3
+    lineExecutionCountForTracefile[12] = 30;        // DA:12,30
+    lineExecutionCountForTracefile[13] = 30;        // DA:13,30
+    lineExecutionCountForTracefile[15] = 3;         // DA:15,3
+    lineExecutionCountForTracefile[16] = 0;         // DA:16,0
+    lineExecutionCountForTracefile[17] = 0;         // DA:17,0
+    lineExecutionCountForTracefile[19] = 3;         // DA:19,3
+    lineExecutionCountForTracefile[20] = 5;         // DA:20,5
+    lineExecutionCountForTracefile[21] = 5;         // DA:21,5
+    lineExecutionCountForTracefile[22] = 5;         // DA:22,5
+    lineExecutionCountForTracefile[23] = 5;         // DA:23,5
+    lineExecutionCountForTracefile[25] = 0;         // DA:25,0
+    lineExecutionCountForTracefile[26] = 0;         // DA:26,0
+    return lineExecutionCountForTracefile;
+  }
+
+  static int[] createLinesExecution2() {
+    int[] lineExecutionCountForTracefile = new int[MAX_LINES_IN_FILE];
+    for (int i = 0; i < MAX_LINES_IN_FILE; ++i) {
+      lineExecutionCountForTracefile[i] = -1;       // no corresponding DA line for line i
+    }
+
+    lineExecutionCountForTracefile[10] = 2;         // DA:10,2
+    lineExecutionCountForTracefile[11] = 2;         // DA:11,2
+    lineExecutionCountForTracefile[12] = 20;        // DA:12,20
+    lineExecutionCountForTracefile[13] = 20;        // DA:13,20
+    lineExecutionCountForTracefile[15] = 2;         // DA:15,2
+    lineExecutionCountForTracefile[16] = 2;         // DA:16,2
+    lineExecutionCountForTracefile[17] = 2;         // DA:17,2
+    lineExecutionCountForTracefile[19] = 0;         // DA:19,0
+    lineExecutionCountForTracefile[20] = 3;         // DA:20,3
+    lineExecutionCountForTracefile[21] = 3;         // DA:21,3
+    lineExecutionCountForTracefile[22] = 3;         // DA:22,3
+    lineExecutionCountForTracefile[23] = 3;         // DA:23,3
+    lineExecutionCountForTracefile[25] = 2;         // DA:25,2
+    lineExecutionCountForTracefile[26] = 2;         // DA:26,2
+    return lineExecutionCountForTracefile;
+  }
+
+  static SourceFileCoverage createSourceFile1(int[] lineExecutionCountForTracefile) {
+    return createSourceFile1(SOURCE_FILENAME, lineExecutionCountForTracefile);
+  }
+
+  // Create source file coverage data, excluding branch coverage
+  static SourceFileCoverage createSourceFile1(String sourceFilename, int[] lineExecutionCount) {
+    SourceFileCoverage sourceFile = new SourceFileCoverage(sourceFilename);
+
+    sourceFile.addLineNumber(FUNC_1, FUNC_1_LINE_NR);
+    sourceFile.addFunctionExecution(FUNC_1, FUNC_1_NR_EXECUTED_LINES_TRACEFILE1);
+
+    sourceFile.addLineNumber(FUNC_2, FUNC_2_LINE_NR);
+    sourceFile.addFunctionExecution(FUNC_2, FUNC_2_NR_EXECUTED_LINES_TRACEFILE1);
+
+    sourceFile.addLineNumber(FUNC_3, FUNC_3_LINE_NR);
+    sourceFile.addFunctionExecution(FUNC_3, FUNC_3_NR_EXECUTED_LINES_TRACEFILE1);
+
+    sourceFile.nrFunctionsFound(NR_FUNCTIONS_FOUND);
+    sourceFile.nrFunctionsHit(NR_FUNCTIONS_HIT_TRACEFILE1);
+
+
+    for (int line = FUNC_1_LINE_NR; line < MAX_LINES_IN_FILE; line++) {
+      if (lineExecutionCount[line] >= 0) {
+        sourceFile.addLine(
+            line, LineCoverage.create(
+                line, lineExecutionCount[line], null));
+      }
+    }
+
+    sourceFile.nrOfLinesWithNonZeroExecution(NR_LINES_HIT_TRACEFILE1);
+    sourceFile.nrOfInstrumentedLines(NR_LINES_FOUND);
+
+    return sourceFile;
+  }
+
+  // Create source file coverage data, excluding branch coverage
+  static SourceFileCoverage createSourceFile2(int[] lineExecutionCount) {
+    SourceFileCoverage sourceFileCoverage = new SourceFileCoverage(SOURCE_FILENAME);
+
+    sourceFileCoverage.addLineNumber(FUNC_1, FUNC_1_LINE_NR);
+    sourceFileCoverage.addFunctionExecution(FUNC_1, FUNC_1_NR_EXECUTED_LINES_TRACEFILE2);
+
+    sourceFileCoverage.addLineNumber(FUNC_2, FUNC_2_LINE_NR);
+    sourceFileCoverage.addFunctionExecution(FUNC_2, FUNC_2_NR_EXECECUTED_LINES_TRACEFILE2);
+
+    sourceFileCoverage.addLineNumber(FUNC_3, FUNC_3_LINE_NR);
+    sourceFileCoverage.addFunctionExecution(FUNC_3, FUNC_3_NR_EXECUTED_LINES_TRACEFILE2);
+
+    sourceFileCoverage.nrFunctionsFound(NR_FUNCTIONS_FOUND);
+    sourceFileCoverage.nrFunctionsHit(NR_FUNCTIONS_HIT_TRACEFILE2);
+
+
+    for (int line = FUNC_1_LINE_NR; line < MAX_LINES_IN_FILE; line++) {
+      if (lineExecutionCount[line] >= 0) {
+        sourceFileCoverage.addLine(
+            line, LineCoverage.create(
+                line, lineExecutionCount[line], null));
+      }
+    }
+
+    sourceFileCoverage.nrOfLinesWithNonZeroExecution(NR_LINES_HIT_TRACEFILE2);
+    sourceFileCoverage.nrOfInstrumentedLines(NR_LINES_FOUND);
+
+    return sourceFileCoverage;
+  }
+
+  private static void assertLinesExecution_tracefile1(Map<Integer, LineCoverage> lines) {
+    int[] lineExecution = createLinesExecution1();
+
+    assertThat(lines.size()).isEqualTo(NR_LINES_FOUND);
+
+    for (int line = 10; line < lineExecution.length; line++) {
+      if (lineExecution[line] >= 0) {
+        LineCoverage lineCoverage = lines.get(line);
+        assertThat(lineCoverage.executionCount()).isEqualTo(lineExecution[line]);
+        assertThat(lineCoverage.lineNumber()).isEqualTo(line);
+        assertThat(lineCoverage.checksum()).isNull();
+      }
+    }
+  }
+
+  private static void assertLines_tracefile2(Map<Integer, LineCoverage> lines) {
+    int[] lineExecution = createLinesExecution2();
+
+    assertThat(lines.size()).isEqualTo(NR_LINES_FOUND);
+
+    for (int lineIndex = 10; lineIndex < lineExecution.length; lineIndex++) {
+      if (lineExecution[lineIndex] >= 0) {
+        LineCoverage line = lines.get(lineIndex);
+        assertThat(line.executionCount()).isEqualTo(lineExecution[lineIndex]);
+        assertThat(line.lineNumber()).isEqualTo(lineIndex);
+        assertThat(line.checksum()).isNull();
+      }
+    }
+  }
+
+  static void assertTracefile1(SourceFileCoverage sourceFile) {
+    Map<String, Integer> lineNumbers = sourceFile.getLineNumbers();
+    assertThat(lineNumbers.size()).isEqualTo(3);
+    assertThat(lineNumbers.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(lineNumbers.get(FUNC_1)).isEqualTo(FUNC_1_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_2)).isEqualTo(FUNC_2_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_3)).isEqualTo(FUNC_3_LINE_NR);
+
+    Map<String, Integer> functionsExecution = sourceFile.getFunctionsExecution();
+    assertThat(functionsExecution.size()).isEqualTo(3);
+    assertThat(functionsExecution.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(functionsExecution.get(FUNC_1)).isEqualTo(FUNC_1_NR_EXECUTED_LINES_TRACEFILE1);
+    assertThat(functionsExecution.get(FUNC_2)).isEqualTo(FUNC_2_NR_EXECUTED_LINES_TRACEFILE1);
+    assertThat(functionsExecution.get(FUNC_3)).isEqualTo(FUNC_3_NR_EXECUTED_LINES_TRACEFILE1);
+
+    assertLinesExecution_tracefile1(sourceFile.getLines());
+
+    assertThat(sourceFile.nrOfInstrumentedLines()).isEqualTo(14);
+    assertThat(sourceFile.nrOfLinesWithNonZeroExecution()).isEqualTo(10);
+  }
+
+  static void assertTracefile2(SourceFileCoverage sourceFile) {
+    Map<String, Integer> lineNumbers = sourceFile.getLineNumbers();
+    assertThat(lineNumbers.size()).isEqualTo(3);
+    assertThat(lineNumbers.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(lineNumbers.get(FUNC_1)).isEqualTo(FUNC_1_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_2)).isEqualTo(FUNC_2_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_3)).isEqualTo(FUNC_3_LINE_NR);
+
+    Map<String, Integer> functionsExecution = sourceFile.getFunctionsExecution();
+    assertThat(functionsExecution.size()).isEqualTo(3);
+    assertThat(functionsExecution.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(functionsExecution.get(FUNC_1)).isEqualTo(FUNC_1_NR_EXECUTED_LINES_TRACEFILE2);
+    assertThat(functionsExecution.get(FUNC_2)).isEqualTo(FUNC_2_NR_EXECECUTED_LINES_TRACEFILE2);
+    assertThat(functionsExecution.get(FUNC_3)).isEqualTo(FUNC_3_NR_EXECUTED_LINES_TRACEFILE2);
+
+    assertLines_tracefile2(sourceFile.getLines());
+
+    assertThat(sourceFile.nrOfInstrumentedLines()).isEqualTo(14);
+    assertThat(sourceFile.nrOfLinesWithNonZeroExecution()).isEqualTo(13);
+  }
+
+  static void assertMergedLineNumbers(TreeMap<String, Integer> lineNumbers) {
+    assertThat(lineNumbers.size()).isEqualTo(3);
+    assertThat(lineNumbers.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(lineNumbers.get(FUNC_1)).isEqualTo(FUNC_1_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_2)).isEqualTo(FUNC_2_LINE_NR);
+    assertThat(lineNumbers.get(FUNC_3)).isEqualTo(FUNC_3_LINE_NR);
+  }
+
+  static void assertMergedFunctionsExecution(TreeMap<String, Integer> functionsExecution) {
+    assertThat(functionsExecution.size()).isEqualTo(3);
+    assertThat(functionsExecution.keySet()).containsAllOf(FUNC_1, FUNC_2, FUNC_3);
+    assertThat(functionsExecution.get(FUNC_1)).isEqualTo(
+        FUNC_1_NR_EXECUTED_LINES_TRACEFILE1 + FUNC_1_NR_EXECUTED_LINES_TRACEFILE2);
+    assertThat(functionsExecution.get(FUNC_2)).isEqualTo(
+        FUNC_2_NR_EXECUTED_LINES_TRACEFILE1 + FUNC_2_NR_EXECECUTED_LINES_TRACEFILE2);
+    assertThat(functionsExecution.get(FUNC_3)).isEqualTo(
+        FUNC_3_NR_EXECUTED_LINES_TRACEFILE1 + FUNC_3_NR_EXECUTED_LINES_TRACEFILE2);
+  }
+
+  static void assertMergedLines(
+      Map<Integer, LineCoverage> lines,
+      int[] linesExecution1,
+      int[] linesExecution2) {
+    assertThat(lines.size()).isEqualTo(14);
+
+    for (int lineIndex = 10; lineIndex < MAX_LINES_IN_FILE; ++lineIndex) {
+      int totalExecutionCount = 0;
+      boolean wasInstrumented = false;
+      if (linesExecution1[lineIndex] >= 0) {
+        totalExecutionCount += linesExecution1[lineIndex];
+        wasInstrumented = true;
+      }
+      if (linesExecution2[lineIndex] >= 0) {
+        totalExecutionCount += linesExecution2[lineIndex];
+        wasInstrumented = true;
+      }
+      if (wasInstrumented) {
+        LineCoverage line = lines.get(lineIndex);
+        assertThat(line.executionCount()).isEqualTo(totalExecutionCount);
+        assertThat(line.lineNumber()).isEqualTo(lineIndex);
+        assertThat(line.checksum()).isNull();
+      }
+    }
+  }
+
+  static void assertMergedSourceFile(
+      SourceFileCoverage merged, int[] linesExecution1, int[] linesExecution2) {
+    assertMergedLineNumbers(merged.getLineNumbers());
+    assertMergedFunctionsExecution(merged.getFunctionsExecution());
+    assertMergedLines(merged.getLines(), linesExecution1, linesExecution2);
+
+    assertThat(merged.nrFunctionsFound()).isEqualTo(NR_FUNCTIONS_FOUND);
+    assertThat(merged.nrFunctionsHit()).isEqualTo(NR_FUNCTIONS_FOUND);
+    assertThat(merged.nrOfLinesWithNonZeroExecution()).isEqualTo(14);
+    assertThat(merged.nrOfInstrumentedLines()).isEqualTo(14);
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovParserTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovParserTest.java
new file mode 100644
index 0000000..e94fb40
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovParserTest.java
@@ -0,0 +1,73 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.TRACEFILE1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.TRACEFILE2;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertTracefile1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertTracefile2;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@LcovParser}.
+ */
+@RunWith(JUnit4.class)
+public class LcovParserTest {
+
+  @Test
+  public void testParseInvalidTracefile() throws IOException {
+    List<SourceFileCoverage> sourceFiles =
+        LcovParser.parse(new ByteArrayInputStream("Invalid lcov tracefile".getBytes(UTF_8)));
+    assertThat(sourceFiles).isEmpty();
+  }
+
+  @Test
+  public void testParseTracefileWithOneSourcefile() throws IOException {
+    List<SourceFileCoverage> sourceFiles = LcovParser.parse(
+        new ByteArrayInputStream(Joiner.on("\n").join(TRACEFILE1).getBytes(UTF_8)));
+    assertThat(sourceFiles).hasSize(1);
+    assertTracefile1(sourceFiles.get(0));
+  }
+
+  @Test
+  public void testParseTracefileWithTwoSourcefiles() throws IOException {
+    List<String> tracefile2ModifiedLines = new ArrayList<>();
+    tracefile2ModifiedLines.addAll(TRACEFILE2);
+    tracefile2ModifiedLines.set(0, "SF:BSOME_OTHER_FILE_THAT_IS_NOT_MERGED");
+
+    List<String> tracefileLines = new ArrayList<>();
+    tracefileLines.addAll(TRACEFILE1);
+    tracefileLines.addAll(tracefile2ModifiedLines);
+
+    InputStream inputStream =
+        new ByteArrayInputStream(Joiner.on("\n").join(tracefileLines).getBytes(UTF_8));
+    List<SourceFileCoverage> sourceFiles = LcovParser.parse(inputStream);
+
+    assertThat(sourceFiles).hasSize(2);
+    assertTracefile1(sourceFiles.get(0));
+    assertTracefile2(sourceFiles.get(1));
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovPrinterTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovPrinterTest.java
new file mode 100644
index 0000000..3844730
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LcovPrinterTest.java
@@ -0,0 +1,94 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.TRACEFILE1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.TRACEFILE1_DIFFERENT_NAME;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createLinesExecution1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createSourceFile1;
+
+import com.google.common.base.Splitter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@LcovPrinter}.
+ */
+@RunWith(JUnit4.class)
+public class LcovPrinterTest {
+
+  private SourceFileCoverage sourceFileCoverage1;
+  private SourceFileCoverage sourceFileCoverage2;
+  private ByteArrayOutputStream byteOutputStream;
+  private Coverage coverage;
+
+  @Before
+  public void init() {
+    sourceFileCoverage1 = createSourceFile1(createLinesExecution1());
+    sourceFileCoverage2 = LcovMergerTestUtils.createSourceFile1(
+        TRACEFILE1_DIFFERENT_NAME.get(0).substring(3), createLinesExecution1());
+    byteOutputStream = new ByteArrayOutputStream();
+    coverage = new Coverage();
+  }
+
+  @Test
+  public void testPrintTwoFiles() throws IOException {
+    coverage.add(sourceFileCoverage1);
+    coverage.add(sourceFileCoverage2);
+
+    assertThat(LcovPrinter.print(byteOutputStream, coverage)).isTrue();
+    byteOutputStream.close();
+
+    Iterable<String> fileLines = Splitter.on('\n').split(byteOutputStream.toString());
+
+    List<String> tracefiles = new ArrayList<>();
+    tracefiles.addAll(TRACEFILE1_DIFFERENT_NAME);
+    tracefiles.addAll(TRACEFILE1);
+
+    // Last line of the file will always be a newline.
+    assertThat(fileLines).hasSize(tracefiles.size() + 1);
+    int lineIndex = 0;
+    for (String line : fileLines) {
+      if (lineIndex == tracefiles.size()) {
+        break;
+      }
+      assertThat(line).isEqualTo(tracefiles.get(lineIndex++));
+    }
+  }
+
+  @Test
+  public void testPrintOneFile() throws IOException {
+    coverage.add(sourceFileCoverage1);
+    assertThat(LcovPrinter.print(byteOutputStream, coverage)).isTrue();
+    byteOutputStream.close();
+    Iterable<String> fileLines = Splitter.on('\n').split(byteOutputStream.toString());
+    // Last line of the file will always be a newline.
+    assertThat(fileLines).hasSize(TRACEFILE1.size() + 1);
+    int lineIndex = 0;
+    for (String line : fileLines) {
+      if (lineIndex == TRACEFILE1.size()) {
+        break;
+      }
+      assertThat(line).isEqualTo(TRACEFILE1.get(lineIndex++));
+    }
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LineCoverageTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LineCoverageTest.java
new file mode 100644
index 0000000..b398f34
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/LineCoverageTest.java
@@ -0,0 +1,126 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@LineExecutionCoverageData}.
+ */
+@RunWith(JUnit4.class)
+public class LineCoverageTest {
+  private static final int LINE1_NR = 10;
+  private static final int LINE1_EXECUTION_COUNT = 10;
+  private static final String LINE1_CHECKSUM = "90345njksdf2";
+  private static final int LINE1_OTHER_TRACEFILE_EXECUTION_COUNT = 5;
+
+  private static final int LINE2_NR = 20;
+  private static final int LINE2_EXECUTION_COUNT = 3;
+  private static final String LINE2_CHECKSUM = null;
+  private static final int LINE2_OTHER_TRACEFILE_EXECUTION_COUNT = 5;
+
+  static LineCoverage getLine1CoverageData() {
+    return LineCoverage.create(
+        LINE1_NR,
+        LINE1_EXECUTION_COUNT,
+        LINE1_CHECKSUM
+    );
+  }
+
+  static LineCoverage getLine1CoverageDataDifferentChecksum() {
+    return LineCoverage.create(
+        LINE1_NR,
+        LINE1_EXECUTION_COUNT,
+        LINE2_CHECKSUM
+    );
+  }
+
+  static LineCoverage getLine1CoverageDataOtherTracefile() {
+    return LineCoverage.create(
+        LINE1_NR,
+        LINE1_OTHER_TRACEFILE_EXECUTION_COUNT,
+        LINE1_CHECKSUM
+    );
+  }
+
+  static LineCoverage getLine2CoverageData() {
+    return LineCoverage.create(
+        LINE2_NR,
+        LINE2_EXECUTION_COUNT,
+        LINE2_CHECKSUM
+    );
+  }
+
+  static LineCoverage getLine2CoverageDataOtherTracefile() {
+    return LineCoverage.create(
+        LINE2_NR,
+        LINE2_OTHER_TRACEFILE_EXECUTION_COUNT,
+        LINE2_CHECKSUM
+    );
+  }
+
+  @Test
+  public void testMergeLine1() {
+    LineCoverage line1 = getLine1CoverageData();
+    LineCoverage line1OtherTracefile = getLine1CoverageDataOtherTracefile();
+    LineCoverage merged = LineCoverage.merge(line1, line1OtherTracefile);
+
+    assertThat(merged.lineNumber()).isEqualTo(LINE1_NR);
+    assertThat(merged.executionCount()).isEqualTo(
+        LINE1_EXECUTION_COUNT + LINE1_OTHER_TRACEFILE_EXECUTION_COUNT);
+    assertThat(merged.checksum()).isEqualTo(LINE1_CHECKSUM);
+  }
+
+  @Test
+  public void testMergeLine2() {
+    LineCoverage line2 = getLine2CoverageData();
+    LineCoverage line2OtherTracefile = getLine2CoverageDataOtherTracefile();
+    LineCoverage merged = LineCoverage.merge(line2, line2OtherTracefile);
+
+    assertThat(merged.lineNumber()).isEqualTo(LINE2_NR);
+    assertThat(merged.executionCount()).isEqualTo(
+        LINE2_EXECUTION_COUNT + LINE2_OTHER_TRACEFILE_EXECUTION_COUNT);
+    assertThat(merged.checksum()).isEqualTo(null);
+  }
+
+  @Test
+  public void testMergeLine1WithLine2() {
+    LineCoverage line1 = getLine1CoverageData();
+    LineCoverage line2 = getLine2CoverageData();
+    try {
+      LineCoverage.merge(line1, line2);
+    } catch (AssertionError er) {
+      return;
+    }
+    fail();
+  }
+
+  @Test
+  public void testMergeLine1DifferentChecksum() {
+    LineCoverage line1 = getLine1CoverageData();
+    LineCoverage line1DiffrentChecksum = getLine1CoverageDataDifferentChecksum();
+    try {
+      LineCoverage.merge(line1, line1DiffrentChecksum);
+    } catch (AssertionError er) {
+      return;
+    }
+    fail();
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/MainTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/MainTest.java
new file mode 100644
index 0000000..07be421
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/MainTest.java
@@ -0,0 +1,58 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Test for {@link Main}.
+ */
+@RunWith(JUnit4.class)
+public class MainTest {
+
+  private Path coverageDir;
+
+  @Before
+  public void createCoverageDirectory() throws IOException {
+    coverageDir = Files.createTempDirectory("coverage-dir");
+  }
+
+  @Test
+  public void testMainEmptyCoverageDir() {
+    assertThat(Main.getLcovTracefiles(coverageDir.toAbsolutePath().toString())).isEmpty();
+  }
+
+  @Test
+  public void testMainGetLcovTracefiles()  throws IOException {
+    Path ccCoverageDir = Files.createTempDirectory(coverageDir, "cc_coverage");
+    Path javaCoverageDir = Files.createTempDirectory(coverageDir, "java_coverage");
+
+    Files.createTempFile(ccCoverageDir, "tracefile1", ".dat");
+    Files.createTempFile(javaCoverageDir, "tracefile2", ".dat");
+
+    List<File> tracefiles = Main.getLcovTracefiles(coverageDir.toAbsolutePath().toString());
+    assertThat(tracefiles).hasSize(2);
+  }
+}
diff --git a/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/SourceFileCoverageTest.java b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/SourceFileCoverageTest.java
new file mode 100644
index 0000000..9b22297
--- /dev/null
+++ b/tools/test/LcovMerger/javatests/com/google/devtools/lcovmerger/SourceFileCoverageTest.java
@@ -0,0 +1,80 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.lcovmerger;
+
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertMergedFunctionsExecution;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertMergedLineNumbers;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertMergedLines;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertMergedSourceFile;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.assertTracefile1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createLinesExecution1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createLinesExecution2;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createSourceFile1;
+import static com.google.devtools.lcovmerger.LcovMergerTestUtils.createSourceFile2;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link SourceFileCoverage}.
+ */
+@RunWith(JUnit4.class)
+public class SourceFileCoverageTest {
+
+  private int[] linesExecution1;
+  private int[] linesExecution2;
+  private SourceFileCoverage sourceFile1;
+  private SourceFileCoverage sourceFile2;
+
+  @Before
+  public void initializeExecutionCountTracefiles() {
+    linesExecution1 = createLinesExecution1();
+    linesExecution2 = createLinesExecution2();
+
+    sourceFile1 = createSourceFile1(linesExecution1);
+    sourceFile2 = createSourceFile2(linesExecution2);
+  }
+
+  @Test
+  public void testCopyConstructor() {
+    assertTracefile1(new SourceFileCoverage(sourceFile1));
+  }
+
+  @Test
+  public void testMergeFunctionNameToLineNumber() {
+    assertMergedLineNumbers(SourceFileCoverage.mergeLineNumbers(sourceFile1, sourceFile2));
+  }
+
+  @Test
+  public void testMergeFunctionNameToExecutionCount() {
+    assertMergedFunctionsExecution(
+        SourceFileCoverage.mergeFunctionsExecution(sourceFile1, sourceFile2));
+  }
+
+  @Test
+  public void testMergeLineNumberToLineExecution() {
+    assertMergedLines(
+        SourceFileCoverage.mergeLines(sourceFile1, sourceFile2), linesExecution1, linesExecution2);
+  }
+
+  @Test
+  public void testMerge() {
+    assertMergedSourceFile(
+        SourceFileCoverage.merge(sourceFile1, sourceFile2), linesExecution1, linesExecution2);
+  }
+
+}