Add testing framework for tools.

This forks the testing harness from https://github.com/google/shaderc
to allow testing CLI tools.

New features needed for SPIRV-Tools include:

1- A new PlaceHolder subclass for spirv shaders.  This place holder
   calls spirv-as to convert assembly input into SPIRV bytecode. This is
   required for most tools in SPIRV-Tools.

2- A minimal testing file for testing basic functionality of spirv-opt.

Add tests for all flags in spirv-opt.

1. Adds tests to check that known flags match the names that each pass
   advertises.
2. Adds tests to check that -O, -Os and --legalize-hlsl schedule the
   expected passes.
3. Adds more functionality to Expect classes to support regular
   expression matching on stderr.
4. Add checks for integer arguments to optimization flags.
5. Fixes #1817 by modifying the parsing of integer arguments in
   flags that take them.
6. Fixes -Oconfig file parsing (#1778). It reads every line of the file
   into a string and then parses that string by tokenizing every group of
   characters between whitespaces (using the standard cin reading
   operator).  This mimics shell command-line parsing, but it does not
   support quoting (and I'm not planning to).
diff --git a/CMakeLists.txt b/CMakeLists.txt
index bcac387..26e2d5a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -29,6 +29,7 @@
 set(SPIRV_TOOLS "SPIRV-Tools")
 
 include(GNUInstallDirs)
+include(cmake/setup_build.cmake)
 
 set(CMAKE_POSITION_INDEPENDENT_CODE ON)
 
diff --git a/cmake/setup_build.cmake b/cmake/setup_build.cmake
new file mode 100644
index 0000000..6ba4c53
--- /dev/null
+++ b/cmake/setup_build.cmake
@@ -0,0 +1,20 @@
+# Find nosetests; see spirv_add_nosetests() for opting in to nosetests in a
+# specific directory.
+find_program(NOSETESTS_EXE NAMES nosetests PATHS $ENV{PYTHON_PACKAGE_PATH})
+if (NOT NOSETESTS_EXE)
+    message(STATUS "SPIRV-Tools: nosetests was not found - python support code will not be tested")
+else()
+    message(STATUS "SPIRV-Tools: nosetests found - python support code will be tested")
+endif()
+
+# Run nosetests on file ${PREFIX}_nosetest.py. Nosetests will look for classes
+# and functions whose names start with "nosetest". The test name will be
+# ${PREFIX}_nosetests.
+function(spirv_add_nosetests PREFIX)
+  if(NOT "${SPIRV_SKIP_TESTS}" AND NOSETESTS_EXE)
+    add_test(
+      NAME ${PREFIX}_nosetests
+      COMMAND ${NOSETESTS_EXE} -m "^[Nn]ose[Tt]est" -v
+        ${CMAKE_CURRENT_SOURCE_DIR}/${PREFIX}_nosetest.py)
+  endif()
+endfunction()
diff --git a/source/opt/dead_variable_elimination.h b/source/opt/dead_variable_elimination.h
index 052a754..40a7bc0 100644
--- a/source/opt/dead_variable_elimination.h
+++ b/source/opt/dead_variable_elimination.h
@@ -26,7 +26,7 @@
 
 class DeadVariableElimination : public MemPass {
  public:
-  const char* name() const override { return "dead-variable-elimination"; }
+  const char* name() const override { return "eliminate-dead-variables"; }
   Status Process() override;
 
   IRContext::Analysis GetPreservedAnalyses() override {
diff --git a/source/opt/flatten_decoration_pass.h b/source/opt/flatten_decoration_pass.h
index c59821d..6a34f5b 100644
--- a/source/opt/flatten_decoration_pass.h
+++ b/source/opt/flatten_decoration_pass.h
@@ -25,7 +25,7 @@
 // See optimizer.hpp for documentation.
 class FlattenDecorationPass : public Pass {
  public:
-  const char* name() const override { return "flatten-decoration"; }
+  const char* name() const override { return "flatten-decorations"; }
   Status Process() override;
 };
 
diff --git a/source/opt/loop_fission.h b/source/opt/loop_fission.h
index ef886c9..e7a59c1 100644
--- a/source/opt/loop_fission.h
+++ b/source/opt/loop_fission.h
@@ -55,7 +55,7 @@
                   bool split_multiple_times = true)
       : split_criteria_(functor), split_multiple_times_(split_multiple_times) {}
 
-  const char* name() const override { return "Loop Fission"; }
+  const char* name() const override { return "loop-fission"; }
 
   Pass::Status Process() override;
 
diff --git a/source/opt/loop_unroller.h b/source/opt/loop_unroller.h
index 98f4d47..eb358ae 100644
--- a/source/opt/loop_unroller.h
+++ b/source/opt/loop_unroller.h
@@ -26,7 +26,7 @@
   LoopUnroller(bool fully_unroll, int unroll_factor)
       : Pass(), fully_unroll_(fully_unroll), unroll_factor_(unroll_factor) {}
 
-  const char* name() const override { return "Loop unroller"; }
+  const char* name() const override { return "loop-unroll"; }
 
   Status Process() override;
 
diff --git a/source/opt/optimizer.cpp b/source/opt/optimizer.cpp
index fc7d266..3065486 100644
--- a/source/opt/optimizer.cpp
+++ b/source/opt/optimizer.cpp
@@ -119,7 +119,7 @@
           .RegisterPass(CreateLocalSingleBlockLoadStoreElimPass())
           .RegisterPass(CreateLocalSingleStoreElimPass())
           .RegisterPass(CreateAggressiveDCEPass())
-          // Split up aggragates so they are easier to deal with.
+          // Split up aggregates so they are easier to deal with.
           .RegisterPass(CreateScalarReplacementPass(0))
           // Remove loads and stores so everything is in intermediate values.
           // Takes care of copy propagation of non-members.
@@ -348,7 +348,7 @@
     if (pass_args.size() == 0) {
       RegisterPass(CreateScalarReplacementPass());
     } else {
-      uint32_t limit = atoi(pass_args.c_str());
+      int limit = atoi(pass_args.c_str());
       if (limit > 0) {
         RegisterPass(CreateScalarReplacementPass(limit));
       } else {
@@ -409,7 +409,7 @@
           CreateLoopFusionPass(static_cast<size_t>(max_registers_per_loop)));
     } else {
       Error(consumer(), nullptr, {},
-            "--loop-fusion must be have a positive integer argument");
+            "--loop-fusion must have a positive integer argument");
       return false;
     }
   } else if (pass_name == "loop-unroll") {
@@ -418,15 +418,24 @@
     RegisterPass(CreateVectorDCEPass());
   } else if (pass_name == "loop-unroll-partial") {
     int factor = (pass_args.size() > 0) ? atoi(pass_args.c_str()) : 0;
-    if (factor != 0) {
+    if (factor > 0) {
       RegisterPass(CreateLoopUnrollPass(false, factor));
     } else {
       Error(consumer(), nullptr, {},
-            "--loop-unroll-partial must have a non-0 integer argument");
+            "--loop-unroll-partial must have a positive integer argument");
       return false;
     }
   } else if (pass_name == "loop-peeling") {
     RegisterPass(CreateLoopPeelingPass());
+  } else if (pass_name == "loop-peeling-threshold") {
+    int factor = (pass_args.size() > 0) ? atoi(pass_args.c_str()) : 0;
+    if (factor > 0) {
+      opt::LoopPeelingPass::SetLoopPeelingThreshold(factor);
+    } else {
+      Error(consumer(), nullptr, {},
+            "--loop-peeling-threshold must have a positive integer argument");
+      return false;
+    }
   } else if (pass_name == "ccp") {
     RegisterPass(CreateCCPPass());
   } else if (pass_name == "O") {
diff --git a/source/opt/replace_invalid_opc.h b/source/opt/replace_invalid_opc.h
index 4d46405..426bcac 100644
--- a/source/opt/replace_invalid_opc.h
+++ b/source/opt/replace_invalid_opc.h
@@ -28,7 +28,7 @@
 // value, the instruction will simply be deleted.
 class ReplaceInvalidOpcodePass : public Pass {
  public:
-  const char* name() const override { return "replace-invalid-opcodes"; }
+  const char* name() const override { return "replace-invalid-opcode"; }
   Status Process() override;
 
  private:
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 2d2e8b2..1fdf5a2 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -221,5 +221,6 @@
 add_subdirectory(link)
 add_subdirectory(opt)
 add_subdirectory(stats)
+add_subdirectory(tools)
 add_subdirectory(util)
 add_subdirectory(val)
diff --git a/test/tools/CMakeLists.txt b/test/tools/CMakeLists.txt
new file mode 100644
index 0000000..cee95ca
--- /dev/null
+++ b/test/tools/CMakeLists.txt
@@ -0,0 +1,18 @@
+# Copyright (c) 2018 Google LLC.
+#
+# 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.
+
+spirv_add_nosetests(expect)
+spirv_add_nosetests(spirv_test_framework)
+
+add_subdirectory(opt)
diff --git a/test/tools/expect.py b/test/tools/expect.py
new file mode 100755
index 0000000..c959650
--- /dev/null
+++ b/test/tools/expect.py
@@ -0,0 +1,677 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+"""A number of common spirv result checks coded in mixin classes.
+
+A test case can use these checks by declaring their enclosing mixin classes
+as superclass and providing the expected_* variables required by the check_*()
+methods in the mixin classes.
+"""
+import difflib
+import os
+import re
+import subprocess
+from spirv_test_framework import SpirvTest
+
+
+def convert_to_unix_line_endings(source):
+  """Converts all line endings in source to be unix line endings."""
+  return source.replace('\r\n', '\n').replace('\r', '\n')
+
+
+def substitute_file_extension(filename, extension):
+  """Substitutes file extension, respecting known shader extensions.
+
+    foo.vert -> foo.vert.[extension] [similarly for .frag, .comp, etc.]
+    foo.glsl -> foo.[extension]
+    foo.unknown -> foo.[extension]
+    foo -> foo.[extension]
+    """
+  if filename[-5:] not in [
+      '.vert', '.frag', '.tesc', '.tese', '.geom', '.comp', '.spvasm'
+  ]:
+    return filename.rsplit('.', 1)[0] + '.' + extension
+  else:
+    return filename + '.' + extension
+
+
+def get_object_filename(source_filename):
+  """Gets the object filename for the given source file."""
+  return substitute_file_extension(source_filename, 'spv')
+
+
+def get_assembly_filename(source_filename):
+  """Gets the assembly filename for the given source file."""
+  return substitute_file_extension(source_filename, 'spvasm')
+
+
+def verify_file_non_empty(filename):
+  """Checks that a given file exists and is not empty."""
+  if not os.path.isfile(filename):
+    return False, 'Cannot find file: ' + filename
+  if not os.path.getsize(filename):
+    return False, 'Empty file: ' + filename
+  return True, ''
+
+
+class ReturnCodeIsZero(SpirvTest):
+  """Mixin class for checking that the return code is zero."""
+
+  def check_return_code_is_zero(self, status):
+    if status.returncode:
+      return False, 'Non-zero return code: {ret}\n'.format(
+          ret=status.returncode)
+    return True, ''
+
+
+class NoOutputOnStdout(SpirvTest):
+  """Mixin class for checking that there is no output on stdout."""
+
+  def check_no_output_on_stdout(self, status):
+    if status.stdout:
+      return False, 'Non empty stdout: {out}\n'.format(out=status.stdout)
+    return True, ''
+
+
+class NoOutputOnStderr(SpirvTest):
+  """Mixin class for checking that there is no output on stderr."""
+
+  def check_no_output_on_stderr(self, status):
+    if status.stderr:
+      return False, 'Non empty stderr: {err}\n'.format(err=status.stderr)
+    return True, ''
+
+
+class SuccessfulReturn(ReturnCodeIsZero, NoOutputOnStdout, NoOutputOnStderr):
+  """Mixin class for checking that return code is zero and no output on
+    stdout and stderr."""
+  pass
+
+
+class NoGeneratedFiles(SpirvTest):
+  """Mixin class for checking that there is no file generated."""
+
+  def check_no_generated_files(self, status):
+    all_files = os.listdir(status.directory)
+    input_files = status.input_filenames
+    if all([f.startswith(status.directory) for f in input_files]):
+      all_files = [os.path.join(status.directory, f) for f in all_files]
+    generated_files = set(all_files) - set(input_files)
+    if len(generated_files) == 0:
+      return True, ''
+    else:
+      return False, 'Extra files generated: {}'.format(generated_files)
+
+
+class CorrectBinaryLengthAndPreamble(SpirvTest):
+  """Provides methods for verifying preamble for a SPIR-V binary."""
+
+  def verify_binary_length_and_header(self, binary, spv_version=0x10000):
+    """Checks that the given SPIR-V binary has valid length and header.
+
+        Returns:
+            False, error string if anything is invalid
+            True, '' otherwise
+        Args:
+            binary: a bytes object containing the SPIR-V binary
+            spv_version: target SPIR-V version number, with same encoding
+                 as the version word in a SPIR-V header.
+        """
+
+    def read_word(binary, index, little_endian):
+      """Reads the index-th word from the given binary file."""
+      word = binary[index * 4:(index + 1) * 4]
+      if little_endian:
+        word = reversed(word)
+      return reduce(lambda w, b: (w << 8) | ord(b), word, 0)
+
+    def check_endianness(binary):
+      """Checks the endianness of the given SPIR-V binary.
+
+            Returns:
+              True if it's little endian, False if it's big endian.
+              None if magic number is wrong.
+            """
+      first_word = read_word(binary, 0, True)
+      if first_word == 0x07230203:
+        return True
+      first_word = read_word(binary, 0, False)
+      if first_word == 0x07230203:
+        return False
+      return None
+
+    num_bytes = len(binary)
+    if num_bytes % 4 != 0:
+      return False, ('Incorrect SPV binary: size should be a multiple'
+                     ' of words')
+    if num_bytes < 20:
+      return False, 'Incorrect SPV binary: size less than 5 words'
+
+    preamble = binary[0:19]
+    little_endian = check_endianness(preamble)
+    # SPIR-V module magic number
+    if little_endian is None:
+      return False, 'Incorrect SPV binary: wrong magic number'
+
+    # SPIR-V version number
+    version = read_word(preamble, 1, little_endian)
+    # TODO(dneto): Recent Glslang uses version word 0 for opengl_compat
+    # profile
+
+    if version != spv_version and version != 0:
+      return False, 'Incorrect SPV binary: wrong version number'
+    # Shaderc-over-Glslang (0x000d....) or
+    # SPIRV-Tools (0x0007....) generator number
+    if read_word(preamble, 2, little_endian) != 0x000d0007 and \
+            read_word(preamble, 2, little_endian) != 0x00070000:
+      return False, ('Incorrect SPV binary: wrong generator magic ' 'number')
+    # reserved for instruction schema
+    if read_word(preamble, 4, little_endian) != 0:
+      return False, 'Incorrect SPV binary: the 5th byte should be 0'
+
+    return True, ''
+
+
+class CorrectObjectFilePreamble(CorrectBinaryLengthAndPreamble):
+  """Provides methods for verifying preamble for a SPV object file."""
+
+  def verify_object_file_preamble(self, filename, spv_version=0x10000):
+    """Checks that the given SPIR-V binary file has correct preamble."""
+
+    success, message = verify_file_non_empty(filename)
+    if not success:
+      return False, message
+
+    with open(filename, 'rb') as object_file:
+      object_file.seek(0, os.SEEK_END)
+      num_bytes = object_file.tell()
+
+      object_file.seek(0)
+
+      binary = bytes(object_file.read())
+      return self.verify_binary_length_and_header(binary, spv_version)
+
+    return True, ''
+
+
+class CorrectAssemblyFilePreamble(SpirvTest):
+  """Provides methods for verifying preamble for a SPV assembly file."""
+
+  def verify_assembly_file_preamble(self, filename):
+    success, message = verify_file_non_empty(filename)
+    if not success:
+      return False, message
+
+    with open(filename) as assembly_file:
+      line1 = assembly_file.readline()
+      line2 = assembly_file.readline()
+      line3 = assembly_file.readline()
+
+    if (line1 != '; SPIR-V\n' or line2 != '; Version: 1.0\n' or
+        (not line3.startswith('; Generator: Google Shaderc over Glslang;'))):
+      return False, 'Incorrect SPV assembly'
+
+    return True, ''
+
+
+class ValidObjectFile(SuccessfulReturn, CorrectObjectFilePreamble):
+  """Mixin class for checking that every input file generates a valid SPIR-V 1.0
+    object file following the object file naming rule, and there is no output on
+    stdout/stderr."""
+
+  def check_object_file_preamble(self, status):
+    for input_filename in status.input_filenames:
+      object_filename = get_object_filename(input_filename)
+      success, message = self.verify_object_file_preamble(
+          os.path.join(status.directory, object_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ValidObjectFile1_3(ReturnCodeIsZero, CorrectObjectFilePreamble):
+  """Mixin class for checking that every input file generates a valid SPIR-V 1.3
+    object file following the object file naming rule, and there is no output on
+    stdout/stderr."""
+
+  def check_object_file_preamble(self, status):
+    for input_filename in status.input_filenames:
+      object_filename = get_object_filename(input_filename)
+      success, message = self.verify_object_file_preamble(
+          os.path.join(status.directory, object_filename), 0x10300)
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ValidObjectFileWithAssemblySubstr(SuccessfulReturn,
+                                        CorrectObjectFilePreamble):
+  """Mixin class for checking that every input file generates a valid object
+
+    file following the object file naming rule, there is no output on
+    stdout/stderr, and the disassmbly contains a specified substring per
+    input.
+  """
+
+  def check_object_file_disassembly(self, status):
+    for an_input in status.inputs:
+      object_filename = get_object_filename(an_input.filename)
+      obj_file = str(os.path.join(status.directory, object_filename))
+      success, message = self.verify_object_file_preamble(obj_file)
+      if not success:
+        return False, message
+      cmd = [status.test_manager.disassembler_path, '--no-color', obj_file]
+      process = subprocess.Popen(
+          args=cmd,
+          stdin=subprocess.PIPE,
+          stdout=subprocess.PIPE,
+          stderr=subprocess.PIPE,
+          cwd=status.directory)
+      output = process.communicate(None)
+      disassembly = output[0]
+      if not isinstance(an_input.assembly_substr, str):
+        return False, 'Missing assembly_substr member'
+      if an_input.assembly_substr not in disassembly:
+        return False, ('Incorrect disassembly output:\n{asm}\n'
+                       'Expected substring not found:\n{exp}'.format(
+                           asm=disassembly, exp=an_input.assembly_substr))
+    return True, ''
+
+
+class ValidNamedObjectFile(SuccessfulReturn, CorrectObjectFilePreamble):
+  """Mixin class for checking that a list of object files with the given
+    names are correctly generated, and there is no output on stdout/stderr.
+
+    To mix in this class, subclasses need to provide expected_object_filenames
+    as the expected object filenames.
+    """
+
+  def check_object_file_preamble(self, status):
+    for object_filename in self.expected_object_filenames:
+      success, message = self.verify_object_file_preamble(
+          os.path.join(status.directory, object_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ValidFileContents(SpirvTest):
+  """Mixin class to test that a specific file contains specific text
+    To mix in this class, subclasses need to provide expected_file_contents as
+    the contents of the file and target_filename to determine the location."""
+
+  def check_file(self, status):
+    target_filename = os.path.join(status.directory, self.target_filename)
+    if not os.path.isfile(target_filename):
+      return False, 'Cannot find file: ' + target_filename
+    with open(target_filename, 'r') as target_file:
+      file_contents = target_file.read()
+      if isinstance(self.expected_file_contents, str):
+        if file_contents == self.expected_file_contents:
+          return True, ''
+        return False, ('Incorrect file output: \n{act}\n'
+                       'Expected:\n{exp}'
+                       'With diff:\n{diff}'.format(
+                           act=file_contents,
+                           exp=self.expected_file_contents,
+                           diff='\n'.join(
+                               list(
+                                   difflib.unified_diff(
+                                       self.expected_file_contents.split('\n'),
+                                       file_contents.split('\n'),
+                                       fromfile='expected_output',
+                                       tofile='actual_output')))))
+      elif isinstance(self.expected_file_contents, type(re.compile(''))):
+        if self.expected_file_contents.search(file_contents):
+          return True, ''
+        return False, ('Incorrect file output: \n{act}\n'
+                       'Expected matching regex pattern:\n{exp}'.format(
+                           act=file_contents,
+                           exp=self.expected_file_contents.pattern))
+    return False, (
+        'Could not open target file ' + target_filename + ' for reading')
+
+
+class ValidAssemblyFile(SuccessfulReturn, CorrectAssemblyFilePreamble):
+  """Mixin class for checking that every input file generates a valid assembly
+    file following the assembly file naming rule, and there is no output on
+    stdout/stderr."""
+
+  def check_assembly_file_preamble(self, status):
+    for input_filename in status.input_filenames:
+      assembly_filename = get_assembly_filename(input_filename)
+      success, message = self.verify_assembly_file_preamble(
+          os.path.join(status.directory, assembly_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ValidAssemblyFileWithSubstr(ValidAssemblyFile):
+  """Mixin class for checking that every input file generates a valid assembly
+    file following the assembly file naming rule, there is no output on
+    stdout/stderr, and all assembly files have the given substring specified
+    by expected_assembly_substr.
+
+    To mix in this class, subclasses need to provde expected_assembly_substr
+    as the expected substring.
+    """
+
+  def check_assembly_with_substr(self, status):
+    for input_filename in status.input_filenames:
+      assembly_filename = get_assembly_filename(input_filename)
+      success, message = self.verify_assembly_file_preamble(
+          os.path.join(status.directory, assembly_filename))
+      if not success:
+        return False, message
+      with open(assembly_filename, 'r') as f:
+        content = f.read()
+        if self.expected_assembly_substr not in convert_to_unix_line_endings(
+            content):
+          return False, ('Incorrect assembly output:\n{asm}\n'
+                         'Expected substring not found:\n{exp}'.format(
+                             asm=content, exp=self.expected_assembly_substr))
+    return True, ''
+
+
+class ValidAssemblyFileWithoutSubstr(ValidAssemblyFile):
+  """Mixin class for checking that every input file generates a valid assembly
+    file following the assembly file naming rule, there is no output on
+    stdout/stderr, and no assembly files have the given substring specified
+    by unexpected_assembly_substr.
+
+    To mix in this class, subclasses need to provde unexpected_assembly_substr
+    as the substring we expect not to see.
+    """
+
+  def check_assembly_for_substr(self, status):
+    for input_filename in status.input_filenames:
+      assembly_filename = get_assembly_filename(input_filename)
+      success, message = self.verify_assembly_file_preamble(
+          os.path.join(status.directory, assembly_filename))
+      if not success:
+        return False, message
+      with open(assembly_filename, 'r') as f:
+        content = f.read()
+        if self.unexpected_assembly_substr in convert_to_unix_line_endings(
+            content):
+          return False, ('Incorrect assembly output:\n{asm}\n'
+                         'Unexpected substring found:\n{unexp}'.format(
+                             asm=content, exp=self.unexpected_assembly_substr))
+    return True, ''
+
+
+class ValidNamedAssemblyFile(SuccessfulReturn, CorrectAssemblyFilePreamble):
+  """Mixin class for checking that a list of assembly files with the given
+    names are correctly generated, and there is no output on stdout/stderr.
+
+    To mix in this class, subclasses need to provide expected_assembly_filenames
+    as the expected assembly filenames.
+    """
+
+  def check_object_file_preamble(self, status):
+    for assembly_filename in self.expected_assembly_filenames:
+      success, message = self.verify_assembly_file_preamble(
+          os.path.join(status.directory, assembly_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ErrorMessage(SpirvTest):
+  """Mixin class for tests that fail with a specific error message.
+
+    To mix in this class, subclasses need to provide expected_error as the
+    expected error message.
+
+    The test should fail if the subprocess was terminated by a signal.
+    """
+
+  def check_has_error_message(self, status):
+    if not status.returncode:
+      return False, ('Expected error message, but returned success from '
+                     'command execution')
+    if status.returncode < 0:
+      # On Unix, a negative value -N for Popen.returncode indicates
+      # termination by signal N.
+      # https://docs.python.org/2/library/subprocess.html
+      return False, ('Expected error message, but command was terminated by '
+                     'signal ' + str(status.returncode))
+    if not status.stderr:
+      return False, 'Expected error message, but no output on stderr'
+    if self.expected_error != convert_to_unix_line_endings(status.stderr):
+      return False, ('Incorrect stderr output:\n{act}\n'
+                     'Expected:\n{exp}'.format(
+                         act=status.stderr, exp=self.expected_error))
+    return True, ''
+
+
+class ErrorMessageSubstr(SpirvTest):
+  """Mixin class for tests that fail with a specific substring in the error
+    message.
+
+    To mix in this class, subclasses need to provide expected_error_substr as
+    the expected error message substring.
+
+    The test should fail if the subprocess was terminated by a signal.
+    """
+
+  def check_has_error_message_as_substring(self, status):
+    if not status.returncode:
+      return False, ('Expected error message, but returned success from '
+                     'command execution')
+    if status.returncode < 0:
+      # On Unix, a negative value -N for Popen.returncode indicates
+      # termination by signal N.
+      # https://docs.python.org/2/library/subprocess.html
+      return False, ('Expected error message, but command was terminated by '
+                     'signal ' + str(status.returncode))
+    if not status.stderr:
+      return False, 'Expected error message, but no output on stderr'
+    if self.expected_error_substr not in convert_to_unix_line_endings(
+        status.stderr):
+      return False, ('Incorrect stderr output:\n{act}\n'
+                     'Expected substring not found in stderr:\n{exp}'.format(
+                         act=status.stderr, exp=self.expected_error_substr))
+    return True, ''
+
+
+class WarningMessage(SpirvTest):
+  """Mixin class for tests that succeed but have a specific warning message.
+
+    To mix in this class, subclasses need to provide expected_warning as the
+    expected warning message.
+    """
+
+  def check_has_warning_message(self, status):
+    if status.returncode:
+      return False, ('Expected warning message, but returned failure from'
+                     ' command execution')
+    if not status.stderr:
+      return False, 'Expected warning message, but no output on stderr'
+    if self.expected_warning != convert_to_unix_line_endings(status.stderr):
+      return False, ('Incorrect stderr output:\n{act}\n'
+                     'Expected:\n{exp}'.format(
+                         act=status.stderr, exp=self.expected_warning))
+    return True, ''
+
+
+class ValidObjectFileWithWarning(NoOutputOnStdout, CorrectObjectFilePreamble,
+                                 WarningMessage):
+  """Mixin class for checking that every input file generates a valid object
+    file following the object file naming rule, with a specific warning message.
+    """
+
+  def check_object_file_preamble(self, status):
+    for input_filename in status.input_filenames:
+      object_filename = get_object_filename(input_filename)
+      success, message = self.verify_object_file_preamble(
+          os.path.join(status.directory, object_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class ValidAssemblyFileWithWarning(NoOutputOnStdout,
+                                   CorrectAssemblyFilePreamble, WarningMessage):
+  """Mixin class for checking that every input file generates a valid assembly
+    file following the assembly file naming rule, with a specific warning
+    message."""
+
+  def check_assembly_file_preamble(self, status):
+    for input_filename in status.input_filenames:
+      assembly_filename = get_assembly_filename(input_filename)
+      success, message = self.verify_assembly_file_preamble(
+          os.path.join(status.directory, assembly_filename))
+      if not success:
+        return False, message
+    return True, ''
+
+
+class StdoutMatch(SpirvTest):
+  """Mixin class for tests that can expect output on stdout.
+
+    To mix in this class, subclasses need to provide expected_stdout as the
+    expected stdout output.
+
+    For expected_stdout, if it's True, then they expect something on stdout but
+    will not check what it is. If it's a string, expect an exact match.  If it's
+    anything else, it is assumed to be a compiled regular expression which will
+    be matched against re.search(). It will expect
+    expected_stdout.search(status.stdout) to be true.
+    """
+
+  def check_stdout_match(self, status):
+    # "True" in this case means we expect something on stdout, but we do not
+    # care what it is, we want to distinguish this from "blah" which means we
+    # expect exactly the string "blah".
+    if self.expected_stdout is True:
+      if not status.stdout:
+        return False, 'Expected something on stdout'
+    elif type(self.expected_stdout) == str:
+      if self.expected_stdout != convert_to_unix_line_endings(status.stdout):
+        return False, ('Incorrect stdout output:\n{ac}\n'
+                       'Expected:\n{ex}'.format(
+                           ac=status.stdout, ex=self.expected_stdout))
+    else:
+      if not self.expected_stdout.search(
+          convert_to_unix_line_endings(status.stdout)):
+        return False, ('Incorrect stdout output:\n{ac}\n'
+                       'Expected to match regex:\n{ex}'.format(
+                           ac=status.stdout, ex=self.expected_stdout.pattern))
+    return True, ''
+
+
+class StderrMatch(SpirvTest):
+  """Mixin class for tests that can expect output on stderr.
+
+    To mix in this class, subclasses need to provide expected_stderr as the
+    expected stderr output.
+
+    For expected_stderr, if it's True, then they expect something on stderr,
+    but will not check what it is. If it's a string, expect an exact match.
+    If it's anything else, it is assumed to be a compiled regular expression
+    which will be matched against re.search(). It will expect
+    expected_stderr.search(status.stderr) to be true.
+    """
+
+  def check_stderr_match(self, status):
+    # "True" in this case means we expect something on stderr, but we do not
+    # care what it is, we want to distinguish this from "blah" which means we
+    # expect exactly the string "blah".
+    if self.expected_stderr is True:
+      if not status.stderr:
+        return False, 'Expected something on stderr'
+    elif type(self.expected_stderr) == str:
+      if self.expected_stderr != convert_to_unix_line_endings(status.stderr):
+        return False, ('Incorrect stderr output:\n{ac}\n'
+                       'Expected:\n{ex}'.format(
+                           ac=status.stderr, ex=self.expected_stderr))
+    else:
+      if not self.expected_stderr.search(
+          convert_to_unix_line_endings(status.stderr)):
+        return False, ('Incorrect stderr output:\n{ac}\n'
+                       'Expected to match regex:\n{ex}'.format(
+                           ac=status.stderr, ex=self.expected_stderr.pattern))
+    return True, ''
+
+
+class StdoutNoWiderThan80Columns(SpirvTest):
+  """Mixin class for tests that require stdout to 80 characters or narrower.
+
+    To mix in this class, subclasses need to provide expected_stdout as the
+    expected stdout output.
+    """
+
+  def check_stdout_not_too_wide(self, status):
+    if not status.stdout:
+      return True, ''
+    else:
+      for line in status.stdout.splitlines():
+        if len(line) > 80:
+          return False, ('Stdout line longer than 80 columns: %s' % line)
+    return True, ''
+
+
+class NoObjectFile(SpirvTest):
+  """Mixin class for checking that no input file has a corresponding object
+    file."""
+
+  def check_no_object_file(self, status):
+    for input_filename in status.input_filenames:
+      object_filename = get_object_filename(input_filename)
+      full_object_file = os.path.join(status.directory, object_filename)
+      print('checking %s' % full_object_file)
+      if os.path.isfile(full_object_file):
+        return False, (
+            'Expected no object file, but found: %s' % full_object_file)
+    return True, ''
+
+
+class NoNamedOutputFiles(SpirvTest):
+  """Mixin class for checking that no specified output files exist.
+
+    The expected_output_filenames member should be full pathnames."""
+
+  def check_no_named_output_files(self, status):
+    for object_filename in self.expected_output_filenames:
+      if os.path.isfile(object_filename):
+        return False, (
+            'Expected no output file, but found: %s' % object_filename)
+    return True, ''
+
+
+class ExecutedListOfPasses(SpirvTest):
+  """Mixin class for checking that a list of passes where executed.
+
+  It works by analyzing the output of the --print-all flag to spirv-opt.
+
+  For this mixin to work, the class member expected_passes should be a sequence
+  of pass names as returned by Pass::name().
+  """
+
+  def check_list_of_executed_passes(self, status):
+    # Collect all the output lines containing a pass name.
+    pass_names = []
+    pass_name_re = re.compile(r'.*IR before pass (?P<pass_name>[\S]+)')
+    for line in status.stderr.splitlines():
+      match = pass_name_re.match(line)
+      if match:
+        pass_names.append(match.group('pass_name'))
+
+    for (expected, actual) in zip(self.expected_passes, pass_names):
+      if expected != actual:
+        return False, (
+            'Expected pass "%s" but found pass "%s"\n' % (expected, actual))
+
+    return True, ''
diff --git a/test/tools/expect_nosetest.py b/test/tools/expect_nosetest.py
new file mode 100755
index 0000000..b591a2d
--- /dev/null
+++ b/test/tools/expect_nosetest.py
@@ -0,0 +1,80 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+"""Tests for the expect module."""
+
+import expect
+from spirv_test_framework import TestStatus
+from nose.tools import assert_equal, assert_true, assert_false
+import re
+
+
+def nosetest_get_object_name():
+  """Tests get_object_filename()."""
+  source_and_object_names = [('a.vert', 'a.vert.spv'), ('b.frag', 'b.frag.spv'),
+                             ('c.tesc', 'c.tesc.spv'), ('d.tese', 'd.tese.spv'),
+                             ('e.geom', 'e.geom.spv'), ('f.comp', 'f.comp.spv'),
+                             ('file', 'file.spv'), ('file.', 'file.spv'),
+                             ('file.uk',
+                              'file.spv'), ('file.vert.',
+                                            'file.vert.spv'), ('file.vert.bla',
+                                                               'file.vert.spv')]
+  actual_object_names = [
+      expect.get_object_filename(f[0]) for f in source_and_object_names
+  ]
+  expected_object_names = [f[1] for f in source_and_object_names]
+
+  assert_equal(actual_object_names, expected_object_names)
+
+
+class TestStdoutMatchADotC(expect.StdoutMatch):
+  expected_stdout = re.compile('a.c')
+
+
+def nosetest_stdout_match_regex_has_match():
+  test = TestStdoutMatchADotC()
+  status = TestStatus(
+      test_manager=None,
+      returncode=0,
+      stdout='0abc1',
+      stderr=None,
+      directory=None,
+      inputs=None,
+      input_filenames=None)
+  assert_true(test.check_stdout_match(status)[0])
+
+
+def nosetest_stdout_match_regex_no_match():
+  test = TestStdoutMatchADotC()
+  status = TestStatus(
+      test_manager=None,
+      returncode=0,
+      stdout='ab',
+      stderr=None,
+      directory=None,
+      inputs=None,
+      input_filenames=None)
+  assert_false(test.check_stdout_match(status)[0])
+
+
+def nosetest_stdout_match_regex_empty_stdout():
+  test = TestStdoutMatchADotC()
+  status = TestStatus(
+      test_manager=None,
+      returncode=0,
+      stdout='',
+      stderr=None,
+      directory=None,
+      inputs=None,
+      input_filenames=None)
+  assert_false(test.check_stdout_match(status)[0])
diff --git a/test/tools/opt/CMakeLists.txt b/test/tools/opt/CMakeLists.txt
new file mode 100644
index 0000000..a6dc526
--- /dev/null
+++ b/test/tools/opt/CMakeLists.txt
@@ -0,0 +1,25 @@
+# Copyright (c) 2018 Google LLC.
+#
+# 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.
+
+if(NOT ${SPIRV_SKIP_TESTS})
+  if(${PYTHONINTERP_FOUND})
+    add_test(NAME spirv_opt_tests
+      COMMAND ${PYTHON_EXECUTABLE}
+      ${CMAKE_CURRENT_SOURCE_DIR}/../spirv_test_framework.py
+      $<TARGET_FILE:spirv-opt> $<TARGET_FILE:spirv-as> $<TARGET_FILE:spirv-dis>
+      --test-dir ${CMAKE_CURRENT_SOURCE_DIR})
+  else()
+    message("Skipping CLI tools tests - Python executable not found")
+  endif()
+endif()
diff --git a/test/tools/opt/flags.py b/test/tools/opt/flags.py
new file mode 100644
index 0000000..628d871
--- /dev/null
+++ b/test/tools/opt/flags.py
@@ -0,0 +1,330 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+
+import placeholder
+import expect
+import re
+
+from spirv_test_framework import inside_spirv_testsuite
+
+
+def empty_main_assembly():
+  return """
+         OpCapability Shader
+         OpMemoryModel Logical GLSL450
+         OpEntryPoint Vertex %4 "main"
+         OpName %4 "main"
+    %2 = OpTypeVoid
+    %3 = OpTypeFunction %2
+    %4 = OpFunction %2 None %3
+    %5 = OpLabel
+         OpReturn
+         OpFunctionEnd"""
+
+
+@inside_spirv_testsuite('SpirvOptBase')
+class TestAssemblyFileAsOnlyParameter(expect.ValidObjectFile1_3):
+  """Tests that spirv-opt accepts a SPIR-V object file."""
+
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  output = placeholder.TempFileName('output.spv')
+  spirv_args = [shader, '-o', output]
+  expected_object_filenames = (output)
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestHelpFlag(expect.ReturnCodeIsZero, expect.StdoutMatch):
+  """Test the --help flag."""
+
+  spirv_args = ['--help']
+  expected_stdout = re.compile(r'.*The SPIR-V binary is read from <input>')
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestValidPassFlags(expect.ValidObjectFile1_3,
+                         expect.ExecutedListOfPasses):
+  """Tests that spirv-opt accepts all valid optimization flags."""
+
+  flags = [
+      '--ccp', '--cfg-cleanup', '--combine-access-chains', '--compact-ids',
+      '--convert-local-access-chains', '--copy-propagate-arrays',
+      '--eliminate-common-uniform', '--eliminate-dead-branches',
+      '--eliminate-dead-code-aggressive', '--eliminate-dead-const',
+      '--eliminate-dead-functions', '--eliminate-dead-inserts',
+      '--eliminate-dead-variables', '--eliminate-insert-extract',
+      '--eliminate-local-multi-store', '--eliminate-local-single-block',
+      '--eliminate-local-single-store', '--flatten-decorations',
+      '--fold-spec-const-op-composite', '--freeze-spec-const',
+      '--if-conversion', '--inline-entry-points-exhaustive', '--loop-fission',
+      '20', '--loop-fusion', '5', '--loop-unroll', '--loop-unroll-partial', '3',
+      '--loop-peeling', '--merge-blocks', '--merge-return', '--loop-unswitch',
+      '--private-to-local', '--reduce-load-size', '--redundancy-elimination',
+      '--remove-duplicates', '--replace-invalid-opcode', '--ssa-rewrite',
+      '--scalar-replacement', '--scalar-replacement=42', '--strength-reduction',
+      '--strip-debug', '--strip-reflect', '--vector-dce', '--workaround-1209',
+      '--unify-const'
+  ]
+  expected_passes = [
+      'ccp',
+      'cfg-cleanup',
+      'combine-access-chains',
+      'compact-ids',
+      'convert-local-access-chains',
+      'copy-propagate-arrays',
+      'eliminate-common-uniform',
+      'eliminate-dead-branches',
+      'eliminate-dead-code-aggressive',
+      'eliminate-dead-const',
+      'eliminate-dead-functions',
+      'eliminate-dead-inserts',
+      'eliminate-dead-variables',
+      # --eliminate-insert-extract runs the simplify-instructions pass.
+      'simplify-instructions',
+      'eliminate-local-multi-store',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'flatten-decorations',
+      'fold-spec-const-op-composite',
+      'freeze-spec-const',
+      'if-conversion',
+      'inline-entry-points-exhaustive',
+      'loop-fission',
+      'loop-fusion',
+      'loop-unroll',
+      'loop-unroll',
+      'loop-peeling',
+      'merge-blocks',
+      'merge-return',
+      'loop-unswitch',
+      'private-to-local',
+      'reduce-load-size',
+      'redundancy-elimination',
+      'remove-duplicates',
+      'replace-invalid-opcode',
+      'ssa-rewrite',
+      'scalar-replacement=100',
+      'scalar-replacement=42',
+      'strength-reduction',
+      'strip-debug',
+      'strip-reflect',
+      'vector-dce',
+      'workaround-1209',
+      'unify-const'
+  ]
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  output = placeholder.TempFileName('output.spv')
+  spirv_args = [shader, '-o', output, '--print-all'] + flags
+  expected_object_filenames = (output)
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestPerformanceOptimizationPasses(expect.ValidObjectFile1_3,
+                                        expect.ExecutedListOfPasses):
+  """Tests that spirv-opt schedules all the passes triggered by -O."""
+
+  flags = ['-O']
+  expected_passes = [
+      'merge-return',
+      'inline-entry-points-exhaustive',
+      'eliminate-dead-code-aggressive',
+      'private-to-local',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'eliminate-dead-code-aggressive',
+      'scalar-replacement=100',
+      'convert-local-access-chains',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'eliminate-dead-code-aggressive',
+      'eliminate-local-multi-store',
+      'eliminate-dead-code-aggressive',
+      'ccp',
+      'eliminate-dead-code-aggressive',
+      'redundancy-elimination',
+      'combine-access-chains',
+      'simplify-instructions',
+      'vector-dce',
+      'eliminate-dead-inserts',
+      'eliminate-dead-branches',
+      'simplify-instructions',
+      'if-conversion',
+      'copy-propagate-arrays',
+      'reduce-load-size',
+      'eliminate-dead-code-aggressive',
+      'merge-blocks',
+      'redundancy-elimination',
+      'eliminate-dead-branches',
+      'merge-blocks',
+      'simplify-instructions',
+  ]
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  output = placeholder.TempFileName('output.spv')
+  spirv_args = [shader, '-o', output, '--print-all'] + flags
+  expected_object_filenames = (output)
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestSizeOptimizationPasses(expect.ValidObjectFile1_3,
+                                 expect.ExecutedListOfPasses):
+  """Tests that spirv-opt schedules all the passes triggered by -Os."""
+
+  flags = ['-Os']
+  expected_passes = [
+      'merge-return',
+      'inline-entry-points-exhaustive',
+      'eliminate-dead-code-aggressive',
+      'private-to-local',
+      'scalar-replacement=100',
+      'convert-local-access-chains',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'eliminate-dead-code-aggressive',
+      'simplify-instructions',
+      'eliminate-dead-inserts',
+      'eliminate-local-multi-store',
+      'eliminate-dead-code-aggressive',
+      'ccp',
+      'eliminate-dead-code-aggressive',
+      'eliminate-dead-branches',
+      'if-conversion',
+      'eliminate-dead-code-aggressive',
+      'merge-blocks',
+      'simplify-instructions',
+      'eliminate-dead-inserts',
+      'redundancy-elimination',
+      'cfg-cleanup',
+      'eliminate-dead-code-aggressive',
+  ]
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  output = placeholder.TempFileName('output.spv')
+  spirv_args = [shader, '-o', output, '--print-all'] + flags
+  expected_object_filenames = (output)
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLegalizationPasses(expect.ValidObjectFile1_3,
+                             expect.ExecutedListOfPasses):
+  """Tests that spirv-opt schedules all the passes triggered by --legalize-hlsl.
+  """
+
+  flags = ['--legalize-hlsl']
+  expected_passes = [
+      'eliminate-dead-branches',
+      'merge-return',
+      'inline-entry-points-exhaustive',
+      'eliminate-dead-functions',
+      'private-to-local',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'eliminate-dead-code-aggressive',
+      'scalar-replacement=0',
+      'eliminate-local-single-block',
+      'eliminate-local-single-store',
+      'eliminate-dead-code-aggressive',
+      'eliminate-local-multi-store',
+      'eliminate-dead-code-aggressive',
+      'ccp',
+      'eliminate-dead-branches',
+      'simplify-instructions',
+      'eliminate-dead-code-aggressive',
+      'copy-propagate-arrays',
+      'vector-dce',
+      'eliminate-dead-inserts',
+      'reduce-load-size',
+      'eliminate-dead-code-aggressive',
+  ]
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  output = placeholder.TempFileName('output.spv')
+  spirv_args = [shader, '-o', output, '--print-all'] + flags
+  expected_object_filenames = (output)
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestScalarReplacementArgsNegative(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --scalar-replacement."""
+
+  spirv_args = ['--scalar-replacement=-10']
+  expected_error_substr = 'must have no arguments or a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestScalarReplacementArgsInvalidNumber(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --scalar-replacement."""
+
+  spirv_args = ['--scalar-replacement=a10f']
+  expected_error_substr = 'must have no arguments or a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopFissionArgsNegative(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-fission."""
+
+  spirv_args = ['--loop-fission=-10']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopFissionArgsInvalidNumber(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-fission."""
+
+  spirv_args = ['--loop-fission=a10f']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopFusionArgsNegative(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-fusion."""
+
+  spirv_args = ['--loop-fusion=-10']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopFusionArgsInvalidNumber(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-fusion."""
+
+  spirv_args = ['--loop-fusion=a10f']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopUnrollPartialArgsNegative(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-unroll-partial."""
+
+  spirv_args = ['--loop-unroll-partial=-10']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopUnrollPartialArgsInvalidNumber(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-unroll-partial."""
+
+  spirv_args = ['--loop-unroll-partial=a10f']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopPeelingThresholdArgsNegative(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-peeling-threshold."""
+
+  spirv_args = ['--loop-peeling-threshold=-10']
+  expected_error_substr = 'must have a positive integer argument'
+
+
+@inside_spirv_testsuite('SpirvOptFlags')
+class TestLoopPeelingThresholdArgsInvalidNumber(expect.ErrorMessageSubstr):
+  """Tests invalid arguments to --loop-peeling-threshold."""
+
+  spirv_args = ['--loop-peeling-threshold=a10f']
+  expected_error_substr = 'must have a positive integer argument'
diff --git a/test/tools/opt/oconfig.py b/test/tools/opt/oconfig.py
new file mode 100644
index 0000000..3372379
--- /dev/null
+++ b/test/tools/opt/oconfig.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+
+import placeholder
+import expect
+import re
+
+from spirv_test_framework import inside_spirv_testsuite
+
+
+def empty_main_assembly():
+  return """
+         OpCapability Shader
+         OpMemoryModel Logical GLSL450
+         OpEntryPoint Vertex %4 "main"
+         OpName %4 "main"
+    %2 = OpTypeVoid
+    %3 = OpTypeFunction %2
+    %4 = OpFunction %2 None %3
+    %5 = OpLabel
+         OpReturn
+         OpFunctionEnd"""
+
+
+@inside_spirv_testsuite('SpirvOptConfigFile')
+class TestOconfigEmpty(expect.SuccessfulReturn):
+  """Tests empty config files are accepted."""
+
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  config = placeholder.ConfigFlagsFile('', '.cfg')
+  spirv_args = [shader, '-o', placeholder.TempFileName('output.spv'), config]
+
+
+@inside_spirv_testsuite('SpirvOptConfigFile')
+class TestOconfigComments(expect.SuccessfulReturn):
+  """Tests empty config files are accepted.
+
+  https://github.com/KhronosGroup/SPIRV-Tools/issues/1778
+  """
+
+  shader = placeholder.FileSPIRVShader(empty_main_assembly(), '.spvasm')
+  config = placeholder.ConfigFlagsFile("""
+# This is a comment.
+-O
+--loop-unroll
+""", '.cfg')
+  spirv_args = [shader, '-o', placeholder.TempFileName('output.spv'), config]
diff --git a/test/tools/placeholder.py b/test/tools/placeholder.py
new file mode 100755
index 0000000..7de3c46
--- /dev/null
+++ b/test/tools/placeholder.py
@@ -0,0 +1,213 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+"""A number of placeholders and their rules for expansion when used in tests.
+
+These placeholders, when used in spirv_args or expected_* variables of
+SpirvTest, have special meanings. In spirv_args, they will be substituted by
+the result of instantiate_for_spirv_args(), while in expected_*, by
+instantiate_for_expectation(). A TestCase instance will be passed in as
+argument to the instantiate_*() methods.
+"""
+
+import os
+import subprocess
+import tempfile
+from string import Template
+
+
+class PlaceHolderException(Exception):
+  """Exception class for PlaceHolder."""
+  pass
+
+
+class PlaceHolder(object):
+  """Base class for placeholders."""
+
+  def instantiate_for_spirv_args(self, testcase):
+    """Instantiation rules for spirv_args.
+
+        This method will be called when the current placeholder appears in
+        spirv_args.
+
+        Returns:
+            A string to replace the current placeholder in spirv_args.
+        """
+    raise PlaceHolderException('Subclass should implement this function.')
+
+  def instantiate_for_expectation(self, testcase):
+    """Instantiation rules for expected_*.
+
+        This method will be called when the current placeholder appears in
+        expected_*.
+
+        Returns:
+            A string to replace the current placeholder in expected_*.
+        """
+    raise PlaceHolderException('Subclass should implement this function.')
+
+
+class FileShader(PlaceHolder):
+  """Stands for a shader whose source code is in a file."""
+
+  def __init__(self, source, suffix, assembly_substr=None):
+    assert isinstance(source, str)
+    assert isinstance(suffix, str)
+    self.source = source
+    self.suffix = suffix
+    self.filename = None
+    # If provided, this is a substring which is expected to be in
+    # the disassembly of the module generated from this input file.
+    self.assembly_substr = assembly_substr
+
+  def instantiate_for_spirv_args(self, testcase):
+    """Creates a temporary file and writes the source into it.
+
+        Returns:
+            The name of the temporary file.
+        """
+    shader, self.filename = tempfile.mkstemp(
+        dir=testcase.directory, suffix=self.suffix)
+    shader_object = os.fdopen(shader, 'w')
+    shader_object.write(self.source)
+    shader_object.close()
+    return self.filename
+
+  def instantiate_for_expectation(self, testcase):
+    assert self.filename is not None
+    return self.filename
+
+
+class ConfigFlagsFile(PlaceHolder):
+  """Stands for a configuration file for spirv-opt generated out of a string."""
+
+  def __init__(self, content, suffix):
+    assert isinstance(content, str)
+    assert isinstance(suffix, str)
+    self.content = content
+    self.suffix = suffix
+    self.filename = None
+
+  def instantiate_for_spirv_args(self, testcase):
+    """Creates a temporary file and writes content into it.
+
+        Returns:
+            The name of the temporary file.
+    """
+    temp_fd, self.filename = tempfile.mkstemp(
+        dir=testcase.directory, suffix=self.suffix)
+    fd = os.fdopen(temp_fd, 'w')
+    fd.write(self.content)
+    fd.close()
+    return '-Oconfig=%s' % self.filename
+
+  def instantiate_for_expectation(self, testcase):
+    assert self.filename is not None
+    return self.filename
+
+
+class FileSPIRVShader(PlaceHolder):
+  """Stands for a source shader file which must be converted to SPIR-V."""
+
+  def __init__(self, source, suffix, assembly_substr=None):
+    assert isinstance(source, str)
+    assert isinstance(suffix, str)
+    self.source = source
+    self.suffix = suffix
+    self.filename = None
+    # If provided, this is a substring which is expected to be in
+    # the disassembly of the module generated from this input file.
+    self.assembly_substr = assembly_substr
+
+  def instantiate_for_spirv_args(self, testcase):
+    """Creates a temporary file, writes the source into it and assembles it.
+
+        Returns:
+            The name of the assembled temporary file.
+        """
+    shader, asm_filename = tempfile.mkstemp(
+        dir=testcase.directory, suffix=self.suffix)
+    shader_object = os.fdopen(shader, 'w')
+    shader_object.write(self.source)
+    shader_object.close()
+    self.filename = '%s.spv' % asm_filename
+    cmd = [
+        testcase.test_manager.assembler_path, asm_filename, '-o', self.filename
+    ]
+    process = subprocess.Popen(
+        args=cmd,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        cwd=testcase.directory)
+    output = process.communicate()
+    assert process.returncode == 0 and not output[0] and not output[1]
+    return self.filename
+
+  def instantiate_for_expectation(self, testcase):
+    assert self.filename is not None
+    return self.filename
+
+
+class StdinShader(PlaceHolder):
+  """Stands for a shader whose source code is from stdin."""
+
+  def __init__(self, source):
+    assert isinstance(source, str)
+    self.source = source
+    self.filename = None
+
+  def instantiate_for_spirv_args(self, testcase):
+    """Writes the source code back to the TestCase instance."""
+    testcase.stdin_shader = self.source
+    self.filename = '-'
+    return self.filename
+
+  def instantiate_for_expectation(self, testcase):
+    assert self.filename is not None
+    return self.filename
+
+
+class TempFileName(PlaceHolder):
+  """Stands for a temporary file's name."""
+
+  def __init__(self, filename):
+    assert isinstance(filename, str)
+    assert filename != ''
+    self.filename = filename
+
+  def instantiate_for_spirv_args(self, testcase):
+    return os.path.join(testcase.directory, self.filename)
+
+  def instantiate_for_expectation(self, testcase):
+    return os.path.join(testcase.directory, self.filename)
+
+
+class SpecializedString(PlaceHolder):
+  """Returns a string that has been specialized based on TestCase.
+
+    The string is specialized by expanding it as a string.Template
+    with all of the specialization being done with each $param replaced
+    by the associated member on TestCase.
+    """
+
+  def __init__(self, filename):
+    assert isinstance(filename, str)
+    assert filename != ''
+    self.filename = filename
+
+  def instantiate_for_spirv_args(self, testcase):
+    return Template(self.filename).substitute(vars(testcase))
+
+  def instantiate_for_expectation(self, testcase):
+    return Template(self.filename).substitute(vars(testcase))
diff --git a/test/tools/spirv_test_framework.py b/test/tools/spirv_test_framework.py
new file mode 100755
index 0000000..03ad08f
--- /dev/null
+++ b/test/tools/spirv_test_framework.py
@@ -0,0 +1,375 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+"""Manages and runs tests from the current working directory.
+
+This will traverse the current working directory and look for python files that
+contain subclasses of SpirvTest.
+
+If a class has an @inside_spirv_testsuite decorator, an instance of that
+class will be created and serve as a test case in that testsuite.  The test
+case is then run by the following steps:
+
+  1. A temporary directory will be created.
+  2. The spirv_args member variable will be inspected and all placeholders in it
+     will be expanded by calling instantiate_for_spirv_args() on placeholders.
+     The transformed list elements are then supplied as arguments to the spirv-*
+     tool under test.
+  3. If the environment member variable exists, its write() method will be
+     invoked.
+  4. All expected_* member variables will be inspected and all placeholders in
+     them will be expanded by calling instantiate_for_expectation() on those
+     placeholders. After placeholder expansion, if the expected_* variable is
+     a list, its element will be joined together with '' to form a single
+     string. These expected_* variables are to be used by the check_*() methods.
+  5. The spirv-* tool will be run with the arguments supplied in spirv_args.
+  6. All check_*() member methods will be called by supplying a TestStatus as
+     argument. Each check_*() method is expected to return a (Success, Message)
+     pair where Success is a boolean indicating success and Message is an error
+     message.
+  7. If any check_*() method fails, the error message is output and the
+     current test case fails.
+
+If --leave-output was not specified, all temporary files and directories will
+be deleted.
+"""
+
+from __future__ import print_function
+
+import argparse
+import fnmatch
+import inspect
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from collections import defaultdict
+from placeholder import PlaceHolder
+
+EXPECTED_BEHAVIOR_PREFIX = 'expected_'
+VALIDATE_METHOD_PREFIX = 'check_'
+
+
+def get_all_variables(instance):
+  """Returns the names of all the variables in instance."""
+  return [v for v in dir(instance) if not callable(getattr(instance, v))]
+
+
+def get_all_methods(instance):
+  """Returns the names of all methods in instance."""
+  return [m for m in dir(instance) if callable(getattr(instance, m))]
+
+
+def get_all_superclasses(cls):
+  """Returns all superclasses of a given class.
+
+    Returns:
+      A list of superclasses of the given class. The order guarantees that
+      * A Base class precedes its derived classes, e.g., for "class B(A)", it
+        will be [..., A, B, ...].
+      * When there are multiple base classes, base classes declared first
+        precede those declared later, e.g., for "class C(A, B), it will be
+        [..., A, B, C, ...]
+    """
+  classes = []
+  for superclass in cls.__bases__:
+    for c in get_all_superclasses(superclass):
+      if c not in classes:
+        classes.append(c)
+  for superclass in cls.__bases__:
+    if superclass not in classes:
+      classes.append(superclass)
+  return classes
+
+
+def get_all_test_methods(test_class):
+  """Gets all validation methods.
+
+    Returns:
+      A list of validation methods. The order guarantees that
+      * A method defined in superclass precedes one defined in subclass,
+        e.g., for "class A(B)", methods defined in B precedes those defined
+        in A.
+      * If a subclass has more than one superclass, e.g., "class C(A, B)",
+        then methods defined in A precedes those defined in B.
+    """
+  classes = get_all_superclasses(test_class)
+  classes.append(test_class)
+  all_tests = [
+      m for c in classes for m in get_all_methods(c)
+      if m.startswith(VALIDATE_METHOD_PREFIX)
+  ]
+  unique_tests = []
+  for t in all_tests:
+    if t not in unique_tests:
+      unique_tests.append(t)
+  return unique_tests
+
+
+class SpirvTest:
+  """Base class for spirv test cases.
+
+    Subclasses define test cases' facts (shader source code, spirv command,
+    result validation), which will be used by the TestCase class for running
+    tests. Subclasses should define spirv_args (specifying spirv_tool command
+    arguments), and at least one check_*() method (for result validation) for
+    a full-fledged test case. All check_*() methods should take a TestStatus
+    parameter and return a (Success, Message) pair, in which Success is a
+    boolean indicating success and Message is an error message. The test passes
+    iff all check_*() methods returns true.
+
+    Often, a test case class will delegate the check_* behaviors by inheriting
+    from other classes.
+    """
+
+  def name(self):
+    return self.__class__.__name__
+
+
+class TestStatus:
+  """A struct for holding run status of a test case."""
+
+  def __init__(self, test_manager, returncode, stdout, stderr, directory,
+               inputs, input_filenames):
+    self.test_manager = test_manager
+    self.returncode = returncode
+    self.stdout = stdout
+    self.stderr = stderr
+    # temporary directory where the test runs
+    self.directory = directory
+    # List of inputs, as PlaceHolder objects.
+    self.inputs = inputs
+    # the names of input shader files (potentially including paths)
+    self.input_filenames = input_filenames
+
+
+class SpirvTestException(Exception):
+  """SpirvTest exception class."""
+  pass
+
+
+def inside_spirv_testsuite(testsuite_name):
+  """Decorator for subclasses of SpirvTest.
+
+    This decorator checks that a class meets the requirements (see below)
+    for a test case class, and then puts the class in a certain testsuite.
+    * The class needs to be a subclass of SpirvTest.
+    * The class needs to have spirv_args defined as a list.
+    * The class needs to define at least one check_*() methods.
+    * All expected_* variables required by check_*() methods can only be
+      of bool, str, or list type.
+    * Python runtime will throw an exception if the expected_* member
+      attributes required by check_*() methods are missing.
+    """
+
+  def actual_decorator(cls):
+    if not inspect.isclass(cls):
+      raise SpirvTestException('Test case should be a class')
+    if not issubclass(cls, SpirvTest):
+      raise SpirvTestException(
+          'All test cases should be subclasses of SpirvTest')
+    if 'spirv_args' not in get_all_variables(cls):
+      raise SpirvTestException('No spirv_args found in the test case')
+    if not isinstance(cls.spirv_args, list):
+      raise SpirvTestException('spirv_args needs to be a list')
+    if not any(
+        [m.startswith(VALIDATE_METHOD_PREFIX) for m in get_all_methods(cls)]):
+      raise SpirvTestException('No check_*() methods found in the test case')
+    if not all(
+        [isinstance(v, (bool, str, list)) for v in get_all_variables(cls)]):
+      raise SpirvTestException(
+          'expected_* variables are only allowed to be bool, str, or '
+          'list type.')
+    cls.parent_testsuite = testsuite_name
+    return cls
+
+  return actual_decorator
+
+
+class TestManager:
+  """Manages and runs a set of tests."""
+
+  def __init__(self, executable_path, assembler_path, disassembler_path):
+    self.executable_path = executable_path
+    self.assembler_path = assembler_path
+    self.disassembler_path = disassembler_path
+    self.num_successes = 0
+    self.num_failures = 0
+    self.num_tests = 0
+    self.leave_output = False
+    self.tests = defaultdict(list)
+
+  def notify_result(self, test_case, success, message):
+    """Call this to notify the manager of the results of a test run."""
+    self.num_successes += 1 if success else 0
+    self.num_failures += 0 if success else 1
+    counter_string = str(self.num_successes + self.num_failures) + '/' + str(
+        self.num_tests)
+    print('%-10s %-40s ' % (counter_string, test_case.test.name()) +
+          ('Passed' if success else '-Failed-'))
+    if not success:
+      print(' '.join(test_case.command))
+      print(message)
+
+  def add_test(self, testsuite, test):
+    """Add this to the current list of test cases."""
+    self.tests[testsuite].append(TestCase(test, self))
+    self.num_tests += 1
+
+  def run_tests(self):
+    for suite in self.tests:
+      print('SPIRV tool test suite: "{suite}"'.format(suite=suite))
+      for x in self.tests[suite]:
+        x.runTest()
+
+
+class TestCase:
+  """A single test case that runs in its own directory."""
+
+  def __init__(self, test, test_manager):
+    self.test = test
+    self.test_manager = test_manager
+    self.inputs = []  # inputs, as PlaceHolder objects.
+    self.file_shaders = []  # filenames of shader files.
+    self.stdin_shader = None  # text to be passed to spirv_tool as stdin
+
+  def setUp(self):
+    """Creates environment and instantiates placeholders for the test case."""
+
+    self.directory = tempfile.mkdtemp(dir=os.getcwd())
+    spirv_args = self.test.spirv_args
+    # Instantiate placeholders in spirv_args
+    self.test.spirv_args = [
+        arg.instantiate_for_spirv_args(self)
+        if isinstance(arg, PlaceHolder) else arg for arg in self.test.spirv_args
+    ]
+    # Get all shader files' names
+    self.inputs = [arg for arg in spirv_args if isinstance(arg, PlaceHolder)]
+    self.file_shaders = [arg.filename for arg in self.inputs]
+
+    if 'environment' in get_all_variables(self.test):
+      self.test.environment.write(self.directory)
+
+    expectations = [
+        v for v in get_all_variables(self.test)
+        if v.startswith(EXPECTED_BEHAVIOR_PREFIX)
+    ]
+    # Instantiate placeholders in expectations
+    for expectation_name in expectations:
+      expectation = getattr(self.test, expectation_name)
+      if isinstance(expectation, list):
+        expanded_expections = [
+            element.instantiate_for_expectation(self)
+            if isinstance(element, PlaceHolder) else element
+            for element in expectation
+        ]
+        setattr(self.test, expectation_name, expanded_expections)
+      elif isinstance(expectation, PlaceHolder):
+        setattr(self.test, expectation_name,
+                expectation.instantiate_for_expectation(self))
+
+  def tearDown(self):
+    """Removes the directory if we were not instructed to do otherwise."""
+    if not self.test_manager.leave_output:
+      shutil.rmtree(self.directory)
+
+  def runTest(self):
+    """Sets up and runs a test, reports any failures and then cleans up."""
+    self.setUp()
+    success = False
+    message = ''
+    try:
+      self.command = [self.test_manager.executable_path]
+      self.command.extend(self.test.spirv_args)
+
+      process = subprocess.Popen(
+          args=self.command,
+          stdin=subprocess.PIPE,
+          stdout=subprocess.PIPE,
+          stderr=subprocess.PIPE,
+          cwd=self.directory)
+      output = process.communicate(self.stdin_shader)
+      test_status = TestStatus(self.test_manager, process.returncode, output[0],
+                               output[1], self.directory, self.inputs,
+                               self.file_shaders)
+      run_results = [
+          getattr(self.test, test_method)(test_status)
+          for test_method in get_all_test_methods(self.test.__class__)
+      ]
+      success, message = zip(*run_results)
+      success = all(success)
+      message = '\n'.join(message)
+    except Exception as e:
+      success = False
+      message = str(e)
+    self.test_manager.notify_result(
+        self, success,
+        message + '\nSTDOUT:\n%s\nSTDERR:\n%s' % (output[0], output[1]))
+    self.tearDown()
+
+
+def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument(
+      'spirv_tool',
+      metavar='path/to/spirv_tool',
+      type=str,
+      nargs=1,
+      help='Path to the spirv-* tool under test')
+  parser.add_argument(
+      'spirv_as',
+      metavar='path/to/spirv-as',
+      type=str,
+      nargs=1,
+      help='Path to spirv-as')
+  parser.add_argument(
+      'spirv_dis',
+      metavar='path/to/spirv-dis',
+      type=str,
+      nargs=1,
+      help='Path to spirv-dis')
+  parser.add_argument(
+      '--leave-output',
+      action='store_const',
+      const=1,
+      help='Do not clean up temporary directories')
+  parser.add_argument(
+      '--test-dir', nargs=1, help='Directory to gather the tests from')
+  args = parser.parse_args()
+  default_path = sys.path
+  root_dir = os.getcwd()
+  if args.test_dir:
+    root_dir = args.test_dir[0]
+  manager = TestManager(args.spirv_tool[0], args.spirv_as[0], args.spirv_dis[0])
+  if args.leave_output:
+    manager.leave_output = True
+  for root, _, filenames in os.walk(root_dir):
+    for filename in fnmatch.filter(filenames, '*.py'):
+      if filename.endswith('nosetest.py'):
+        # Skip nose tests, which are for testing functions of
+        # the test framework.
+        continue
+      sys.path = default_path
+      sys.path.append(root)
+      mod = __import__(os.path.splitext(filename)[0])
+      for _, obj, in inspect.getmembers(mod):
+        if inspect.isclass(obj) and hasattr(obj, 'parent_testsuite'):
+          manager.add_test(obj.parent_testsuite, obj())
+  manager.run_tests()
+  if manager.num_failures > 0:
+    sys.exit(-1)
+
+
+if __name__ == '__main__':
+  main()
diff --git a/test/tools/spirv_test_framework_nosetest.py b/test/tools/spirv_test_framework_nosetest.py
new file mode 100755
index 0000000..c0fbed5
--- /dev/null
+++ b/test/tools/spirv_test_framework_nosetest.py
@@ -0,0 +1,155 @@
+# Copyright (c) 2018 Google LLC
+#
+# 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.
+
+from spirv_test_framework import get_all_test_methods, get_all_superclasses
+from nose.tools import assert_equal, with_setup
+
+
+# Classes to be used in testing get_all_{superclasses|test_methods}()
+class Root:
+
+  def check_root(self):
+    pass
+
+
+class A(Root):
+
+  def check_a(self):
+    pass
+
+
+class B(Root):
+
+  def check_b(self):
+    pass
+
+
+class C(Root):
+
+  def check_c(self):
+    pass
+
+
+class D(Root):
+
+  def check_d(self):
+    pass
+
+
+class E(Root):
+
+  def check_e(self):
+    pass
+
+
+class H(B, C, D):
+
+  def check_h(self):
+    pass
+
+
+class I(E):
+
+  def check_i(self):
+    pass
+
+
+class O(H, I):
+
+  def check_o(self):
+    pass
+
+
+class U(A, O):
+
+  def check_u(self):
+    pass
+
+
+class X(U, A):
+
+  def check_x(self):
+    pass
+
+
+class R1:
+
+  def check_r1(self):
+    pass
+
+
+class R2:
+
+  def check_r2(self):
+    pass
+
+
+class Multi(R1, R2):
+
+  def check_multi(self):
+    pass
+
+
+def nosetest_get_all_superclasses():
+  """Tests get_all_superclasses()."""
+
+  assert_equal(get_all_superclasses(A), [Root])
+  assert_equal(get_all_superclasses(B), [Root])
+  assert_equal(get_all_superclasses(C), [Root])
+  assert_equal(get_all_superclasses(D), [Root])
+  assert_equal(get_all_superclasses(E), [Root])
+
+  assert_equal(get_all_superclasses(H), [Root, B, C, D])
+  assert_equal(get_all_superclasses(I), [Root, E])
+
+  assert_equal(get_all_superclasses(O), [Root, B, C, D, E, H, I])
+
+  assert_equal(get_all_superclasses(U), [Root, B, C, D, E, H, I, A, O])
+  assert_equal(get_all_superclasses(X), [Root, B, C, D, E, H, I, A, O, U])
+
+  assert_equal(get_all_superclasses(Multi), [R1, R2])
+
+
+def nosetest_get_all_methods():
+  """Tests get_all_test_methods()."""
+  assert_equal(get_all_test_methods(A), ['check_root', 'check_a'])
+  assert_equal(get_all_test_methods(B), ['check_root', 'check_b'])
+  assert_equal(get_all_test_methods(C), ['check_root', 'check_c'])
+  assert_equal(get_all_test_methods(D), ['check_root', 'check_d'])
+  assert_equal(get_all_test_methods(E), ['check_root', 'check_e'])
+
+  assert_equal(
+      get_all_test_methods(H),
+      ['check_root', 'check_b', 'check_c', 'check_d', 'check_h'])
+  assert_equal(get_all_test_methods(I), ['check_root', 'check_e', 'check_i'])
+
+  assert_equal(
+      get_all_test_methods(O), [
+          'check_root', 'check_b', 'check_c', 'check_d', 'check_e', 'check_h',
+          'check_i', 'check_o'
+      ])
+
+  assert_equal(
+      get_all_test_methods(U), [
+          'check_root', 'check_b', 'check_c', 'check_d', 'check_e', 'check_h',
+          'check_i', 'check_a', 'check_o', 'check_u'
+      ])
+  assert_equal(
+      get_all_test_methods(X), [
+          'check_root', 'check_b', 'check_c', 'check_d', 'check_e', 'check_h',
+          'check_i', 'check_a', 'check_o', 'check_u', 'check_x'
+      ])
+
+  assert_equal(
+      get_all_test_methods(Multi), ['check_r1', 'check_r2', 'check_multi'])
diff --git a/tools/opt/opt.cpp b/tools/opt/opt.cpp
index 1440871..fcd260e 100644
--- a/tools/opt/opt.cpp
+++ b/tools/opt/opt.cpp
@@ -138,7 +138,7 @@
   --eliminate-dead-functions
                Deletes functions that cannot be reached from entry points or
                exported functions.
-  --eliminate-dead-insert
+  --eliminate-dead-inserts
                Deletes unreferenced inserts into composites, most notably
                unused stores to vector components, that are not removed by
                aggressive dead code elimination.
@@ -383,10 +383,20 @@
     return false;
   }
 
-  while (!input_file.eof()) {
-    std::string flag;
-    input_file >> flag;
-    if (flag.length() > 0 && flag[0] != '#') {
+  std::string line;
+  while (std::getline(input_file, line)) {
+    // Ignore empty lines and lines starting with the comment marker '#'.
+    if (line.length() == 0 || line[0] == '#') {
+      continue;
+    }
+
+    // Tokenize the line.  Add all found tokens to the list of found flags. This
+    // mimics the way the shell will parse whitespace on the command line. NOTE:
+    // This does not support quoting and it is not intended to.
+    std::istringstream iss(line);
+    while (!iss.eof()) {
+      std::string flag;
+      iss >> flag;
       file_flags->push_back(flag);
     }
   }