diff --git a/.github/config/rubocop_linter_action.yml b/.github/config/rubocop_linter_action.yml
index bf25887..249432a 100644
--- a/.github/config/rubocop_linter_action.yml
+++ b/.github/config/rubocop_linter_action.yml
@@ -14,6 +14,7 @@ check_name: 'RuboCop Results'
 versions:
   - rubocop: 'latest'
   - rubocop-rake: 'latest'
+  - rubocop-rspec: 'latest'
 
 # Description: RuboCop configuration file path relative to the workspace.
 # Valid options: A valid file path inside of the workspace.
diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml
index d0ead59..f3c7902 100644
--- a/.github/workflows/branch.yml
+++ b/.github/workflows/branch.yml
@@ -11,8 +11,22 @@ jobs:
 
     steps:
       - uses: actions/checkout@v3
-
       - name: RuboCop Linter
         uses: andrewmcodes/rubocop-linter-action@v3.3.0
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  spec:
+    runs-on: ubuntu-latest
+    env:
+      COVERAGE: 1
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          # runs 'bundle install' and caches installed gems automatically
+          bundler-cache: true
+      - name: Run tests
+        run: bundle exec rspec
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index d88193b..01f6cd8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -11,8 +11,22 @@ jobs:
 
     steps:
       - uses: actions/checkout@v3
-
       - name: RuboCop Linter
         uses: andrewmcodes/rubocop-linter-action@v3.3.0
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  spec:
+    runs-on: ubuntu-latest
+    env:
+      COVERAGE: 1
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          # runs 'bundle install' and caches installed gems automatically
+          bundler-cache: true
+      - name: Run tests
+        run: bundle exec rspec
diff --git a/.gitignore b/.gitignore
index e2fadf6..6ac918f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ vendor
 # Ignore import and export files.
 ./*.csv
 ./*.json
+
+# Ignore test outputs
+coverage
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..b521ced
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,4 @@
+--require spec_helper.rb
+--color
+--format RSpec::Github::Formatter
+--format documentation
diff --git a/.rubocop.yml b/.rubocop.yml
index 8dbe18b..6a8a12e 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,5 +1,6 @@
 require:
   - rubocop-rake
+  - rubocop-rspec
 
 AllCops:
   NewCops: enable
@@ -10,3 +11,8 @@ AllCops:
 
 Naming/MemoizedInstanceVariableName:
   Enabled: false
+
+# Favor more explicit contexts over limited nesting.
+RSpec/NestedGroups:
+  AllowedGroups:
+    - context
diff --git a/Gemfile b/Gemfile
index 0faf261..93dc9a6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,5 +7,14 @@ gemspec
 group :development do
   gem 'rake', '~> 13.0'
   gem 'rubocop', '~> 1.48'
+  gem 'rubocop-factory_bot', '~> 2.23'
   gem 'rubocop-rake', '~> 0.6'
+  gem 'rubocop-rspec', '~> 2.22'
+end
+
+group :test do
+  gem 'factory_bot', '~> 6.2'
+  gem 'rspec', '~> 3.12'
+  gem 'rspec-github', '~> 2.4'
+  gem 'simplecov', '~> 0.22'
 end
diff --git a/Gemfile.lock b/Gemfile.lock
index 14af1c3..88f22c7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -14,12 +14,12 @@ PATH
 GEM
   remote: https://rubygems.org/
   specs:
-    activemodel (7.0.4.3)
-      activesupport (= 7.0.4.3)
-    activerecord (7.0.4.3)
-      activemodel (= 7.0.4.3)
-      activesupport (= 7.0.4.3)
-    activesupport (7.0.4.3)
+    activemodel (7.0.5)
+      activesupport (= 7.0.5)
+    activerecord (7.0.5)
+      activemodel (= 7.0.5)
+      activesupport (= 7.0.5)
+    activesupport (7.0.5)
       concurrent-ruby (~> 1.0, >= 1.0.2)
       i18n (>= 1.6, < 2)
       minitest (>= 5.1)
@@ -29,14 +29,18 @@ GEM
     ast (2.4.2)
     bson (4.15.0)
     concurrent-ruby (1.2.2)
-    down (5.4.0)
+    diff-lcs (1.5.0)
+    docile (1.4.0)
+    down (5.4.1)
       addressable (~> 2.8)
-    faraday (2.7.4)
+    factory_bot (6.2.1)
+      activesupport (>= 5.0.0)
+    faraday (2.7.6)
       faraday-net_http (>= 2.0, < 3.1)
       ruby2_keywords (>= 0.0.4)
     faraday-net_http (3.0.2)
     file_exists (0.2.0)
-    i18n (1.13.0)
+    i18n (1.14.1)
       concurrent-ruby (~> 1.0)
     ibm_db (5.4.1)
       activerecord (< 7.1)
@@ -48,14 +52,31 @@ GEM
     mongo (2.18.2)
       bson (>= 4.14.1, < 5.0.0)
     parallel (1.23.0)
-    parser (3.2.2.1)
+    parser (3.2.2.3)
       ast (~> 2.4.1)
+      racc
     public_suffix (5.0.1)
+    racc (1.7.0)
     rainbow (3.1.1)
     rake (13.0.6)
     regexp_parser (2.8.0)
     rexml (3.2.5)
-    rubocop (1.51.0)
+    rspec (3.12.0)
+      rspec-core (~> 3.12.0)
+      rspec-expectations (~> 3.12.0)
+      rspec-mocks (~> 3.12.0)
+    rspec-core (3.12.2)
+      rspec-support (~> 3.12.0)
+    rspec-expectations (3.12.3)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.12.0)
+    rspec-github (2.4.0)
+      rspec-core (~> 3.0)
+    rspec-mocks (3.12.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.12.0)
+    rspec-support (3.12.0)
+    rubocop (1.52.0)
       json (~> 2.3)
       parallel (~> 1.10)
       parser (>= 3.2.0.0)
@@ -65,13 +86,27 @@ GEM
       rubocop-ast (>= 1.28.0, < 2.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 2.4.0, < 3.0)
-    rubocop-ast (1.28.1)
+    rubocop-ast (1.29.0)
       parser (>= 3.2.1.0)
+    rubocop-capybara (2.18.0)
+      rubocop (~> 1.41)
+    rubocop-factory_bot (2.23.1)
+      rubocop (~> 1.33)
     rubocop-rake (0.6.0)
       rubocop (~> 1.0)
+    rubocop-rspec (2.22.0)
+      rubocop (~> 1.33)
+      rubocop-capybara (~> 2.17)
+      rubocop-factory_bot (~> 2.22)
     ruby-progressbar (1.13.0)
     ruby2_keywords (0.0.5)
-    sequel (5.68.0)
+    sequel (5.69.0)
+    simplecov (0.22.0)
+      docile (~> 1.1)
+      simplecov-html (~> 0.11)
+      simplecov_json_formatter (~> 0.1)
+    simplecov-html (0.12.3)
+    simplecov_json_formatter (0.1.4)
     thor (1.2.2)
     tzinfo (2.0.6)
       concurrent-ruby (~> 1.0)
@@ -86,9 +121,15 @@ PLATFORMS
 
 DEPENDENCIES
   cmr-entity-resolution!
+  factory_bot (~> 6.2)
   rake (~> 13.0)
+  rspec (~> 3.12)
+  rspec-github (~> 2.4)
   rubocop (~> 1.48)
+  rubocop-factory_bot (~> 2.23)
   rubocop-rake (~> 0.6)
+  rubocop-rspec (~> 2.22)
+  simplecov (~> 0.22)
 
 BUNDLED WITH
    2.4.10
diff --git a/Rakefile b/Rakefile
index 140695c..444527c 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,9 +1,12 @@
 # frozen_string_literal: true
 
+require 'rspec/core/rake_task'
 require 'rubocop/rake_task'
 
-task default: [:rubocop]
+task default: %i[spec rubocop]
 
-RuboCop::RakeTask.new do |task|
+RuboCop::RakeTask.new(:rubocop) do |task|
   task.requires << 'rubocop'
 end
+
+RSpec::Core::RakeTask.new(:spec)
diff --git a/lib/config.rb b/lib/config.rb
index 983ecf5..bd38b18 100644
--- a/lib/config.rb
+++ b/lib/config.rb
@@ -29,7 +29,7 @@ def from_file(path)
 
   def initialize
     defaults
-    yield self
+    yield self if block_given?
     initialize_logger
   end
 
diff --git a/lib/destination.rb b/lib/destination.rb
index dde69cc..855bb92 100644
--- a/lib/destination.rb
+++ b/lib/destination.rb
@@ -6,12 +6,18 @@
 
 # Helper methods for loading destinations.
 module Destination
+  class InvalidDestination < RuntimeError; end
+
   # Load a destination based on the configuration file.
   #
   # @param destination_config [Hash] Destination configuration from the config
   #   file.
   # @return [Destination::Base]
+  #
+  # @raise [InvalidDestination] When the destination type can not be found.
   def self.from_config(destination_config)
     Object.const_get("Destination::#{destination_config[:type]}").new(destination_config)
+  rescue NameError
+    raise InvalidDestination, "Unknown destination type #{destination_config[:type]}"
   end
 end
diff --git a/lib/destination/base.rb b/lib/destination/base.rb
index 7cdf638..eab4f8c 100644
--- a/lib/destination/base.rb
+++ b/lib/destination/base.rb
@@ -32,7 +32,7 @@ def add_record(record)
     #
     # @return [Hash]
     def defaults
-      { field_map: [] }
+      { field_map: {}, transformations: [] }
     end
   end
 end
diff --git a/lib/destination/jsonl.rb b/lib/destination/jsonl.rb
index 4db708f..cf838b4 100644
--- a/lib/destination/jsonl.rb
+++ b/lib/destination/jsonl.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require 'json'
 require_relative 'file'
 
 module Destination
diff --git a/lib/export.rb b/lib/export.rb
index 3ef7377..f2f2496 100644
--- a/lib/export.rb
+++ b/lib/export.rb
@@ -2,10 +2,12 @@
 
 require 'faraday'
 require_relative 'destination'
-require_relative 'transformation'
+require_relative 'transformable'
 
 # Exports data from senzing into a configured destination.
 class Export
+  include Transformable
+
   def initialize(config)
     @config = config
   end
@@ -25,12 +27,12 @@ def from_file
   private
 
   def process_record(record)
-    Transformation.transform(@config, record, destination.config[:transformations])
+    record = transform(destination, record)
 
     # Map fields.
     output = {}
     @destination.config[:field_map].each do |field, map|
-      output[map] = record[field]
+      output[map.to_sym] = record[field]
     end
 
     output
diff --git a/lib/filter.rb b/lib/filter.rb
index c13ce65..5a5a618 100644
--- a/lib/filter.rb
+++ b/lib/filter.rb
@@ -5,11 +5,13 @@
 
 # Filter records during processing.
 module Filter
+  class InvalidFilter < RuntimeError; end
+
   # Pass a record through all configured filters to determine if it should be
   # kept.
   #
   # @param config [Config] Configuration object.
-  # @param record [CSV::Row] The record to filter.
+  # @param record [Hash] The record to filter.
   # @return [Boolean] Whether or not this record should be included.
   def self.filter(config, record)
     result = config.filters.all? do |filter|
@@ -21,9 +23,19 @@ def self.filter(config, record)
     result
   end
 
+  # Load a filter based on the defined configuration.
+  #
+  # @param filter_config [Hash|String] The name of a filter or a configuration
+  #   hash for one.
+  # @return [Filter::Base]
+  #
+  # @raise [InvalidFilter] When the filter can not be found.
   def self.filter_from_config(filter_config)
     return Object.const_get("Filter::#{filter_config}").new unless filter_config.is_a?(Hash)
 
     Object.const_get("Filter::#{filter_config[:filter]}").new(filter_config)
+  rescue NameError
+    type = filter_config.is_a?(Hash) ? filter_config[:filter] : filter_config
+    raise InvalidFilter, "Unknown filter type #{type}"
   end
 end
diff --git a/lib/filter/base.rb b/lib/filter/base.rb
index 17aaa4b..9fc6c2c 100644
--- a/lib/filter/base.rb
+++ b/lib/filter/base.rb
@@ -7,6 +7,13 @@ def initialize(filter_config = {})
       @filter_config = defaults.merge(filter_config)
     end
 
+    # Returns the configuration for the current filter.
+    #
+    # @return [Hash]
+    def config
+      @filter_config
+    end
+
     # Apply the filter to a record to determine if it should be kept.
     #
     # @param record [CSV::ROW] The record to apply the filter to.
diff --git a/lib/filterable.rb b/lib/filterable.rb
new file mode 100644
index 0000000..6bfeace
--- /dev/null
+++ b/lib/filterable.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require_relative 'filter'
+
+# Helper module that adds transformation support.
+module Filterable
+  # Apply filters to a record
+  #
+  # @param record [Hash] The record to apply filters to.
+  # @return [Boolean] Whether or not the record should be included.
+  def filter(record)
+    Filter.filter(@config, record)
+  end
+end
diff --git a/lib/import.rb b/lib/import.rb
index ac27474..b51b9f5 100644
--- a/lib/import.rb
+++ b/lib/import.rb
@@ -1,12 +1,15 @@
 # frozen_string_literal: true
 
-require_relative 'filter'
+require_relative 'filterable'
 require_relative 'senzing'
 require_relative 'source'
-require_relative 'transformation'
+require_relative 'transformable'
 
 # Imports data from a configured destination into Senzing.
 class Import
+  include Filterable
+  include Transformable
+
   # Instantiate a new import object.
   #
   # @param config [Config]
@@ -21,26 +24,13 @@ def import
       source.each do |record|
         next unless filter(record)
 
-        transform(source, record)
-        senzing.upsert_record(record)
+        senzing.upsert_record(transform(source, record))
       end
     end
   end
 
   private
 
-  # Apply filters to a record
-  #
-  # @param record [Hash] The record to apply filters to.
-  # @return [Boolean] Whether or not the record should be included.
-  def filter(record)
-    Filter.filter(@config, record)
-  end
-
-  def transform(source, record)
-    Transformation.transform(@config, record, source.config[:transformations])
-  end
-
   # Loads the Senzing client and proxies calls.
   #
   # @return [Senzing]
diff --git a/lib/senzing.rb b/lib/senzing.rb
index 57f69b3..1d5a5b6 100644
--- a/lib/senzing.rb
+++ b/lib/senzing.rb
@@ -4,6 +4,8 @@
 
 # Client for the Senzing API
 class Senzing
+  attr_reader :config
+
   # Instantiates a new Senzing client object.
   #
   # @param config [Config]
diff --git a/lib/source.rb b/lib/source.rb
index 6ed35d9..5f508c0 100644
--- a/lib/source.rb
+++ b/lib/source.rb
@@ -5,11 +5,17 @@
 
 # Helper methods for loading sources.
 module Source
+  class InvalidSource < RuntimeError; end
+
   # Load a source based on the configuration.
   #
   # @param source_config [Hash] Source configuration.
   # @return [Source::Base]
+  #
+  # @raise [InvalidSource] When the source type can not be found.
   def self.from_config(source_config)
     Object.const_get("Source::#{source_config[:type]}").new(source_config)
+  rescue NameError
+    raise InvalidSource, "Unknown source type #{source_config[:type]}"
   end
 end
diff --git a/lib/source/base.rb b/lib/source/base.rb
index d07eee0..942ccee 100644
--- a/lib/source/base.rb
+++ b/lib/source/base.rb
@@ -52,7 +52,7 @@ def field_mapper(field)
     #
     # @return [Hash]
     def defaults
-      { field_map: [] }
+      { field_map: {} }
     end
   end
 end
diff --git a/lib/transformable.rb b/lib/transformable.rb
new file mode 100644
index 0000000..2842c09
--- /dev/null
+++ b/lib/transformable.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require_relative 'transformation'
+
+# Helper module that adds transformation support.
+module Transformable
+  # Apply transformations to a record
+  #
+  # @param context [Source::Base|Destination::Base] Context (source or destination) for the record.
+  # @param record [Hash] The record to be transformed.
+  # @return [Hash]
+  def transform(context, record)
+    Transformation.transform(@config, record, context.config[:transformations])
+  end
+end
diff --git a/lib/transformation.rb b/lib/transformation.rb
index 4a1017e..b9d995a 100644
--- a/lib/transformation.rb
+++ b/lib/transformation.rb
@@ -1,25 +1,27 @@
 # frozen_string_literal: true
 
-require_relative 'transformation/satic_prefix'
+require_relative 'transformation/static_prefix'
 require_relative 'transformation/split_value'
 require_relative 'transformation/static_value'
 
 # Transform records during processing.
 module Transformation
+  class InvalidTransform < RuntimeError; end
+
   # Pass a record through all configured transforms.
   #
   # @param config [Config] Configuration object.
-  # @param record [CSV::Row] The record to transform.
+  # @param record [Hash] The record to transform.
   # @param transformations [Array<Hash>] Array of transformation configurations.
-  # @return [Boolean] Whether or not this record was transformed.
+  # @return [Hash] The resulting record after all transformations have been applied.
   def self.transform(config, record, transformations)
-    result = transformations.any? do |filter|
-      transform_from_config(filter).transform(record)
+    result = transformations.any? do |transformation|
+      transform_from_config(transformation).transform(record)
     end
 
     config.logger.info("Transformations applied to record #{record[:RECORD_ID]}") if result
 
-    result
+    record
   end
 
   # Load a transformation based on the defined configuration.
@@ -27,9 +29,14 @@ def self.transform(config, record, transformations)
   # @param transform_config [Hash|String] The name of a transformation or a
   #   configuration hash for one.
   # @return [Transformation::Base]
+  #
+  # @raise [InvalidTransform] When the transformation can not be found.
   def self.transform_from_config(transform_config)
     return Object.const_get("Transformation::#{transform_config}").new unless transform_config.is_a?(Hash)
 
     Object.const_get("Transformation::#{transform_config[:transform]}").new(transform_config)
+  rescue NameError
+    type = transform_config.is_a?(Hash) ? transform_config[:transform] : filter_config
+    raise InvalidTransform, "Unknown transformation type #{type}"
   end
 end
diff --git a/lib/transformation/base.rb b/lib/transformation/base.rb
index fb5f0ef..be11d5d 100644
--- a/lib/transformation/base.rb
+++ b/lib/transformation/base.rb
@@ -7,6 +7,13 @@ def initialize(transform_config = {})
       @transform_config = defaults.merge(transform_config)
     end
 
+    # Returns the configuration for the current transformation.
+    #
+    # @return [Hash]
+    def config
+      @transform_config
+    end
+
     # Apply the transformation to a record to determine.
     #
     # @param record [CSV::ROW] The record to transform.
diff --git a/lib/transformation/satic_prefix.rb b/lib/transformation/static_prefix.rb
similarity index 100%
rename from lib/transformation/satic_prefix.rb
rename to lib/transformation/static_prefix.rb
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..279fd7b
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'factory_bot'
+
+# Configure code coverage reporting.
+if ENV['COVERAGE']
+  require 'simplecov'
+
+  SimpleCov.minimum_coverage 95
+  SimpleCov.start do
+    add_filter '/spec/'
+
+    track_files 'lib/**/*.rb'
+  end
+end
+
+RSpec.configure do |config|
+  config.include FactoryBot::Syntax::Methods
+end
+
+# Include shared examples and factories.
+require_relative 'support/examples'
+require_relative 'support/factories'
diff --git a/spec/support/examples.rb b/spec/support/examples.rb
new file mode 100644
index 0000000..651e4c7
--- /dev/null
+++ b/spec/support/examples.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require_relative 'examples/filter'
+require_relative 'examples/proxy_method'
+require_relative 'examples/source'
+require_relative 'examples/transform'
diff --git a/spec/support/examples/filter.rb b/spec/support/examples/filter.rb
new file mode 100644
index 0000000..73cfd8a
--- /dev/null
+++ b/spec/support/examples/filter.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'filter' do |result|
+  subject(:filter) do
+    defined?(filter_config) ? described_class.new(filter_config) : described_class.new
+  end
+
+  it "returns #{result}" do
+    expect(filter.filter(record)).to be(result)
+  end
+end
diff --git a/spec/support/examples/proxy_method.rb b/spec/support/examples/proxy_method.rb
new file mode 100644
index 0000000..decd2f6
--- /dev/null
+++ b/spec/support/examples/proxy_method.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'proxy method' do |method, klass|
+  before do
+    allow(klass).to receive(:new).and_return(object)
+  end
+
+  context 'when object has not been created' do
+    before { subject.instance_variable_set("@#{method}", nil) }
+
+    it 'returns a new object' do
+      expect(subject.send(method)).to eq(object)
+    end
+
+    it 'instantiates a new object' do
+      subject.send(method)
+      expect(klass).to have_received(:new)
+    end
+  end
+
+  context 'when object has been created' do
+    before { subject.instance_variable_set("@#{method}", object) }
+
+    it 'returns the previously set object' do
+      expect(subject.send(method)).to eq(object)
+    end
+
+    it 'does not try to create a new object' do
+      expect(klass).not_to have_received(:new)
+    end
+  end
+end
diff --git a/spec/support/examples/source.rb b/spec/support/examples/source.rb
new file mode 100644
index 0000000..f0e7740
--- /dev/null
+++ b/spec/support/examples/source.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'source' do
+  subject(:source) { described_class.new(source_config) }
+
+  let(:rows) do
+    [
+      { record_id: '1', first_name: 'Shredward', last_name: 'Whiskers' },
+      { record_id: '2', first_name: 'Timmy', last_name: 'Tester' },
+      { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson' }
+    ]
+  end
+
+  it 'yields expected records' do
+    expect { |probe| source.each(&probe) }.to yield_successive_args(*rows)
+  end
+end
diff --git a/spec/support/examples/transform.rb b/spec/support/examples/transform.rb
new file mode 100644
index 0000000..ac5a742
--- /dev/null
+++ b/spec/support/examples/transform.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'transform' do |modified: true, unmodified: true|
+  subject(:transform) do
+    defined?(transform_config) ? described_class.new(transform_config) : described_class.new
+  end
+
+  if modified
+    context 'when the transformation conditions are met' do
+      it 'transforms the record' do
+        transform.transform(record)
+        expect(record).to eq(expected)
+      end
+    end
+  end
+
+  if unmodified
+    context 'when the transformation conditions are not met' do
+      subject(:transform) do
+        local_config.nil? ? described_class.new : described_class.new(local_config)
+      end
+
+      let(:local_record) { defined?(unmatched_record) ? unmatched_record : record }
+      let(:local_config) do
+        if defined?(unmatched_config)
+          unmatched_config
+        elsif defined?(transform_config)
+          transform_config
+        end
+      end
+
+      before do
+        raise 'Unmatched config or record must be set' unless defined?(unmatched_config) || defined?(unmatched_record)
+      end
+
+      it 'does not transform the record' do
+        transform.transform(local_record)
+        expect(local_record).to eq(local_record) # rubocop:disable RSpec/IdenticalEqualityAssertion
+      end
+    end
+  end
+end
diff --git a/spec/support/factories.rb b/spec/support/factories.rb
new file mode 100644
index 0000000..e987ac4
--- /dev/null
+++ b/spec/support/factories.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Allow rspec mocks to be used by factories.
+FactoryBot::SyntaxRunner.class_eval do
+  include RSpec::Mocks::ExampleMethods
+end
+
+require_relative 'factories/config_factory'
+require_relative 'factories/senzing_factory'
+require_relative 'factories/destination/csv_factory'
+require_relative 'factories/source/csv_factory'
diff --git a/spec/support/factories/config_factory.rb b/spec/support/factories/config_factory.rb
new file mode 100644
index 0000000..b08f2e7
--- /dev/null
+++ b/spec/support/factories/config_factory.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :config, class: 'Config' do
+    sources { [{ type: 'CSV' }] }
+    destination { { type: 'CSV' } }
+    filters { [{ filter: 'NonHuman' }] }
+  end
+end
diff --git a/spec/support/factories/destination/csv_factory.rb b/spec/support/factories/destination/csv_factory.rb
new file mode 100644
index 0000000..37c8f49
--- /dev/null
+++ b/spec/support/factories/destination/csv_factory.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :destination_csv, class: 'Destination::CSV' do
+    initialize_with do
+      new(field_map: {
+            ENTITY_ID: 'entity_id',
+            RECORD_ID: 'record_id',
+            PRIMARY_NAME_FIRST: 'first_name',
+            PRIMARY_NAME_LAST: 'last_name'
+          })
+    end
+
+    after(:build) do |factory|
+      allow(factory).to receive(:add_record)
+    end
+  end
+end
diff --git a/spec/support/factories/senzing_factory.rb b/spec/support/factories/senzing_factory.rb
new file mode 100644
index 0000000..c1d44a2
--- /dev/null
+++ b/spec/support/factories/senzing_factory.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :senzing, class: 'Senzing' do
+    initialize_with { new(build(:config)) }
+
+    after(:build) do |factory|
+      allow(factory).to receive(:upsert_record)
+    end
+  end
+end
diff --git a/spec/support/factories/source/csv_factory.rb b/spec/support/factories/source/csv_factory.rb
new file mode 100644
index 0000000..7a46560
--- /dev/null
+++ b/spec/support/factories/source/csv_factory.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :source_csv, class: 'Source::CSV' do
+    initialize_with { new({ field_map: [] }) }
+
+    after(:build) do |factory|
+      record = { id: 1, first_name: 'Shredward', last_name: 'Whiskers' }
+      allow(factory).to receive(:each).and_yield(record)
+    end
+  end
+end
diff --git a/spec/unit/config_spec.rb b/spec/unit/config_spec.rb
new file mode 100644
index 0000000..8080164
--- /dev/null
+++ b/spec/unit/config_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+
+RSpec.describe Config do
+  describe '#initialize' do
+    it 'can be initialized without a block' do
+      expect(described_class.new).to be_a(described_class)
+    end
+
+    it 'can be initialized with a block' do
+      expect { |probe| described_class.new(&probe) }.to yield_with_args(described_class)
+    end
+  end
+
+  describe '.from_file' do
+    subject(:from_file) { described_class.from_file(path) }
+
+    let(:path) { File.join(__dir__, '../../config/config.sample.yml') }
+
+    it 'can be loaded from a file' do
+      expect(from_file).to be_a(described_class)
+    end
+
+    it 'loads expected options' do
+      expect(from_file.sources.first[:type]).to eq('CSV')
+    end
+  end
+end
diff --git a/spec/unit/destination/csv_spec.rb b/spec/unit/destination/csv_spec.rb
new file mode 100644
index 0000000..eded7f0
--- /dev/null
+++ b/spec/unit/destination/csv_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/destination/csv'
+
+describe Destination::CSV do
+  # Since we're using an actual CSV object we can't use message spies so disable
+  # this cop.
+  # rubocop:disable RSpec/MessageSpies
+  describe '#add_record' do
+    subject(:destination) { described_class.new(destination_config) }
+
+    let(:destination_config) do
+      { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] }
+    end
+    let(:records) do
+      [
+        { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' },
+        { record_id: 2, first_name: 'Timmy', last_name: 'Tester' },
+        { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson',
+          address: { street: '123 street road' } }
+      ]
+    end
+    let(:csv) { CSV.new(StringIO.new, write_headers: true, headers: destination_config[:headers]) }
+
+    before do
+      allow(CSV).to receive(:open).and_return(csv)
+    end
+
+    it 'writes the record to the file' do
+      expect(csv).to receive(:puts)
+
+      destination.add_record(records[0])
+    end
+
+    it 'properly formats the record' do
+      expect(csv).to receive(:puts).with(['C-40', 'Bobbert', '"Bobby" Bobberson', '123 street road'])
+
+      destination.add_record(records[2])
+    end
+  end
+  # rubocop:enable RSpec/MessageSpies
+end
diff --git a/spec/unit/destination/file_spec.rb b/spec/unit/destination/file_spec.rb
new file mode 100644
index 0000000..f6db126
--- /dev/null
+++ b/spec/unit/destination/file_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/destination/file'
+
+describe Destination::File do
+  # Since we're using an actual StringIO object we can't use message spies so
+  # disable this cop.
+  # rubocop:disable RSpec/MessageSpies
+  describe '#add_record' do
+    subject(:destination) { described_class.new(destination_config) }
+
+    let(:destination_config) do
+      { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] }
+    end
+    let(:records) do
+      [
+        { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' },
+        { record_id: 2, first_name: 'Timmy', last_name: 'Tester' },
+        { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson',
+          address: { street: '123 street road' } }
+      ]
+    end
+    let(:file) { StringIO.new }
+
+    before do
+      allow(File).to receive(:open).and_return(file)
+    end
+
+    it 'writes the record to the file' do
+      expect(file).to receive(:puts)
+
+      destination.add_record(records[0])
+    end
+
+    it 'properly formats the record' do
+      expect(file).to receive(:puts).with(records[2].to_s)
+
+      destination.add_record(records[2])
+    end
+  end
+  # rubocop:enable RSpec/MessageSpies
+end
diff --git a/spec/unit/destination/jsonl_spec.rb b/spec/unit/destination/jsonl_spec.rb
new file mode 100644
index 0000000..b0c588b
--- /dev/null
+++ b/spec/unit/destination/jsonl_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/destination/jsonl'
+
+describe Destination::JSONL do
+  # Since we're using an actual StringIO object we can't use message spies so
+  # disable this cop.
+  # rubocop:disable RSpec/MessageSpies
+  describe '#add_record' do
+    subject(:destination) { described_class.new(destination_config) }
+
+    let(:destination_config) do
+      { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] }
+    end
+    let(:records) do
+      [
+        { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' },
+        { record_id: 2, first_name: 'Timmy', last_name: 'Tester' },
+        { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson',
+          address: { street: '123 street road' } }
+      ]
+    end
+    let(:file) { StringIO.new }
+
+    before do
+      allow(File).to receive(:open).and_return(file)
+    end
+
+    it 'writes the record to the file' do
+      expect(file).to receive(:puts)
+
+      destination.add_record(records[0])
+    end
+
+    it 'properly formats the record' do
+      expect(file).to receive(:puts).with(records[2].to_json)
+
+      destination.add_record(records[2])
+    end
+  end
+  # rubocop:enable RSpec/MessageSpies
+end
diff --git a/spec/unit/destination/mongo_spec.rb b/spec/unit/destination/mongo_spec.rb
new file mode 100644
index 0000000..2bd948e
--- /dev/null
+++ b/spec/unit/destination/mongo_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/destination/mongo'
+
+describe Destination::Mongo do
+  describe '#add_record' do
+    subject(:destination) { described_class.new(destination_config) }
+
+    let(:destination_config) do
+      { path: '/rspec/output.csv', headers: %w[record_id first_name last_name address_street] }
+    end
+    let(:records) do
+      [
+        { record_id: 1, first_name: 'Shredward', last_name: 'Whiskers' },
+        { record_id: 2, first_name: 'Timmy', last_name: 'Tester' },
+        { record_id: 'C-40', first_name: 'Bobbert', last_name: '"Bobby" Bobberson',
+          address: { street: '123 street road' } }
+      ]
+    end
+    let(:client) do
+      instance_double(Mongo::Client).tap do |client|
+        allow(client).to receive(:use).and_return(client)
+        allow(client).to receive(:[]).and_return(collection)
+      end
+    end
+    let(:collection) do
+      instance_double(Mongo::Collection).tap do |collection|
+        allow(collection).to receive(:insert_one)
+      end
+    end
+
+    before do
+      allow(Mongo::Client).to receive(:new).and_return(client)
+    end
+
+    it 'writes the record to the database' do
+      destination.add_record(records[0])
+      expect(collection).to have_received(:insert_one)
+    end
+
+    it 'properly formats the record' do
+      destination.add_record(records[2])
+      expect(collection).to have_received(:insert_one).with(records[2])
+    end
+  end
+end
diff --git a/spec/unit/destination_spec.rb b/spec/unit/destination_spec.rb
new file mode 100644
index 0000000..753bd1c
--- /dev/null
+++ b/spec/unit/destination_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/destination'
+
+RSpec.describe Destination do
+  let(:config) { build(:config, destination: destination_config) }
+  let(:destination_config) { { type: 'CSV' } }
+
+  describe '.from_config' do
+    subject(:concrete) { described_class.from_config(config.destination) }
+
+    context 'when a valid destination type is provided' do
+      it 'returns the proper destination' do
+        expect(concrete).to be_a(Destination::CSV)
+      end
+    end
+
+    context 'when an invalid destination type is provided' do
+      let(:destination_config) { { type: 'Invalid' } }
+
+      it 'raises an exception' do
+        expect { concrete }.to raise_error(Destination::InvalidDestination)
+      end
+    end
+  end
+end
diff --git a/spec/unit/export_spec.rb b/spec/unit/export_spec.rb
new file mode 100644
index 0000000..af41a2a
--- /dev/null
+++ b/spec/unit/export_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/export'
+
+RSpec.describe Export do
+  subject(:export) { described_class.new(config) }
+
+  let(:config) { build(:config) }
+  let(:destination) { build(:destination_csv) }
+
+  describe '#from_file' do
+    let(:record) { { RECORD_ID: 1, PRIMARY_NAME_FIRST: 'Shredward', PRIMARY_NAME_LAST: 'Whiskers' } }
+    let(:mapped) { { entity_id: 2, first_name: 'Shredward', last_name: 'Whiskers', record_id: 1 } }
+    let(:entity) do
+      {
+        RESOLVED_ENTITY: {
+          ENTITY_ID: 2,
+          RECORDS: [record]
+        }
+      }.to_json
+    end
+
+    before do
+      allow(File).to receive(:readlines).and_return([entity])
+      allow(Destination::CSV).to receive(:new).and_return(destination)
+      allow(Transformation).to receive(:transform).and_return(record.merge(ENTITY_ID: 2))
+    end
+
+    context 'when the record has not been transformed' do
+      it 'sends the unmodified record to the destination' do
+        export.from_file
+
+        expect(destination).to have_received(:add_record).with(mapped)
+      end
+    end
+
+    context 'when the record has been transformed' do
+      let(:record) { super().merge(PRIMARY_NAME_FIRST: 'Bobert') }
+      let(:mapped) { super().merge(first_name: 'Bobert') }
+
+      it 'sends the modified record to the destination' do
+        export.from_file
+
+        expect(destination).to have_received(:add_record).with(mapped)
+      end
+    end
+  end
+end
diff --git a/spec/unit/filter/non_human_spec.rb b/spec/unit/filter/non_human_spec.rb
new file mode 100644
index 0000000..6b962e4
--- /dev/null
+++ b/spec/unit/filter/non_human_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/filter/non_human'
+
+describe Filter::NonHuman do
+  subject(:filter) { described_class.new }
+
+  let(:record) do
+    { record_id: 1, PRIMARY_NAME_FIRST: 'Shredward', PRIMARY_NAME_MIDDLE: 'Shreddy', PRIMARY_NAME_LAST: 'Whiskers' }
+  end
+
+  describe '#filter' do
+    context 'when the name does not match any terms' do
+      include_examples 'filter', true
+    end
+
+    context 'when the name matches an anywhere term' do
+      [
+        { PRIMARY_NAME_FIRST: 'Shredward AND ASSOC' },
+        { PRIMARY_NAME_MIDDLE: 'SPORTS & ENT Shreddy' },
+        { PRIMARY_NAME_LAST: 'Whiskers "EXPUNGED" Seal' }
+      ].each do |updates|
+        include_examples 'filter', false do
+          let(:record) { super().merge(updates) }
+        end
+      end
+    end
+
+    context 'when the name matches a beginning term' do
+      let(:record) { super().merge(PRIMARY_NAME_FIRST: 'REGISTER AGENT Shredward') }
+
+      include_examples 'filter', false
+    end
+
+    context 'when the name matches an ending term' do
+      let(:record) { super().merge(PRIMARY_NAME_LAST: 'Whiskers AUTO DADDY') }
+
+      include_examples 'filter', false
+    end
+
+    context 'when the name matches an exact term' do
+      let(:record) do
+        super().merge(PRIMARY_NAME_FIRST: 'EXPUNGED', PRIMARY_NAME_MIDDLE: 'DEFENDANT', PRIMARY_NAME_LAST: 'RECORD')
+      end
+
+      include_examples 'filter', false
+    end
+
+    context 'when the name matches a middle term' do
+      let(:record) { super().merge(PRIMARY_NAME_MIDDLE: 'Shreddy FOUNDATION') }
+
+      include_examples 'filter', false
+    end
+  end
+end
diff --git a/spec/unit/filter/value_is_spec.rb b/spec/unit/filter/value_is_spec.rb
new file mode 100644
index 0000000..5e1cbac
--- /dev/null
+++ b/spec/unit/filter/value_is_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/filter/value_is'
+
+describe Filter::ValueIs do
+  subject(:filter) { described_class.new(filter_config) }
+
+  let(:filter_config) { { field: 'test_field', value: 'test value' } }
+  let(:record) { { record_id: 1, test_field: 'test value' } }
+
+  describe '#filter' do
+    context 'when the operator is not reversed' do
+      context 'when the value matches' do
+        include_examples 'filter', true
+      end
+
+      context 'when the value does not match' do
+        let(:filter_config) { super().merge(value: 'bad value') }
+
+        include_examples 'filter', false
+      end
+    end
+
+    context 'when the operator is reversed' do
+      let(:filter_config) { super().merge(inverse: true) }
+
+      context 'when the value matches' do
+        include_examples 'filter', false
+      end
+
+      context 'when the value does not match' do
+        let(:filter_config) { super().merge(value: 'bad value') }
+
+        include_examples 'filter', true
+      end
+    end
+  end
+end
diff --git a/spec/unit/filter_spec.rb b/spec/unit/filter_spec.rb
new file mode 100644
index 0000000..2be5de4
--- /dev/null
+++ b/spec/unit/filter_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/filter'
+
+describe Filter do
+  let(:config) { build(:config, filters: filter_config) }
+  let(:record) { {} }
+  let(:filter_config) { [{ filter: 'ValueIs' }] }
+
+  describe '.filter' do
+    subject(:result) { described_class.filter(config, record) }
+
+    let(:filter) { instance_double(Filter::ValueIs) }
+
+    before do
+      allow(described_class).to receive(:filter_from_config).and_return(filter)
+      allow(filter).to receive(:filter).and_return(!filtered)
+    end
+
+    context 'when the record is filtered out' do
+      let(:filtered) { true }
+
+      it 'returns false' do
+        expect(result).to be(false)
+      end
+    end
+
+    context 'when the record is not filtered out' do
+      let(:filtered) { false }
+
+      it 'returns true' do
+        expect(result).to be(true)
+      end
+    end
+  end
+
+  describe '.filter_from_config' do
+    subject(:concrete) { described_class.filter_from_config(filter_config.first) }
+
+    context 'when a valid filter type is provided' do
+      it 'returns the proper filter' do
+        expect(concrete).to be_a(Filter::ValueIs)
+      end
+    end
+
+    context 'when an invalid filter type is provided' do
+      let(:filter_config) { [{ filter: 'Invalid' }] }
+
+      it 'raises an exception' do
+        expect { concrete }.to raise_error(Filter::InvalidFilter)
+      end
+    end
+  end
+end
diff --git a/spec/unit/import_spec.rb b/spec/unit/import_spec.rb
new file mode 100644
index 0000000..b29bf7b
--- /dev/null
+++ b/spec/unit/import_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/import'
+
+RSpec.describe Import do
+  subject(:import) { described_class.new(config) }
+
+  let(:config) { build(:config) }
+  let(:senzing) { build(:senzing) }
+
+  describe '#import' do
+    let(:source) { build(:source_csv) }
+    let(:senzing) { build(:senzing) }
+    let(:record) { { id: 1, first_name: 'Shredward', last_name: 'Whiskers' } }
+
+    before do
+      allow(Source::CSV).to receive(:new).and_return(source)
+      allow(Senzing).to receive(:new).and_return(senzing)
+      allow(Filter).to receive(:filter).and_return(included)
+      allow(Transformation).to receive(:transform).and_return(record)
+    end
+
+    context 'when the record is not filtered out' do
+      let(:included) { true }
+
+      context 'when the record has not been transformed' do
+        it 'sends the unmodified record to senzing' do
+          import.import
+
+          expect(senzing).to have_received(:upsert_record).with(record)
+        end
+      end
+
+      context 'when the record has been transformed' do
+        let(:record) { super().merge(test_field: 'test value') }
+
+        it 'sends the modified record to senzing' do
+          import.import
+
+          expect(senzing).to have_received(:upsert_record).with(record)
+        end
+      end
+    end
+
+    context 'when the record is filtered out' do
+      let(:included) { false }
+
+      it 'does not send the record to senzing' do
+        import.import
+
+        expect(senzing).not_to have_received(:upsert_record)
+      end
+    end
+  end
+
+  describe '#senzing' do
+    include_examples 'proxy method', :senzing, Senzing do
+      let(:object) { senzing }
+    end
+  end
+end
diff --git a/spec/unit/senzing_spec.rb b/spec/unit/senzing_spec.rb
new file mode 100644
index 0000000..6a4f2df
--- /dev/null
+++ b/spec/unit/senzing_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/senzing'
+
+RSpec.describe Senzing do
+  subject(:senzing) { described_class.new(config) }
+
+  let(:senzing_config) { { data_source: 'RSPEC', tls: false } }
+  let(:stubs)  { Faraday::Adapter::Test::Stubs.new }
+  let(:client) do
+    Faraday.new { |b| b.adapter(:test, stubs) }
+  end
+
+  let(:config) do
+    build(:config, senzing: senzing_config)
+  end
+
+  before do
+    allow(Faraday).to receive(:new).and_return(client).and_yield(client)
+  end
+
+  describe '#initialize' do
+    it 'applies defaults to the config' do
+      expect(senzing.config.senzing[:host]).to eq('localhost')
+    end
+
+    it 'does not override configs with defaults' do
+      expect(senzing.config.senzing[:data_source]).to eq('RSPEC')
+    end
+  end
+
+  # Since we're using an actual Faraday object we can't use message spies so
+  # disable this cop.
+  # rubocop:disable RSpec/MessageSpies
+  describe '#upsert_record' do
+    let(:record) { { RECORD_ID: '1234' } }
+
+    it 'writes the records to Senzing' do
+      stubs.put('/data-sources/RSPEC/records/1234') { [200, {}, ''] }
+      expect(client).to receive(:put).and_call_original
+
+      senzing.upsert_record(record)
+      stubs.verify_stubbed_calls
+    end
+  end
+  # rubocop:enable RSpec/MessageSpies
+
+  describe '#client' do
+    include_examples 'proxy method', :client, Faraday do
+      let(:object) { client }
+    end
+  end
+
+  describe '#url' do
+    subject(:url) { URI(senzing.send(:url)) }
+
+    let(:senzing_config) { super().merge(host: 'rspec', port: 1234) }
+
+    it 'returns the expected host' do
+      expect(url.host).to eq(senzing_config[:host])
+    end
+
+    it 'returns the expected port' do
+      expect(url.port).to eq(senzing_config[:port])
+    end
+
+    context 'when TLS is enabled' do
+      let(:senzing_config) { super().merge(tls: true) }
+
+      it 'returns the expected scheme' do
+        expect(url.scheme).to eq('https')
+      end
+    end
+
+    context 'when TLS is disabled' do
+      let(:senzing_config) { super().merge(tls: false) }
+
+      it 'returns the expected scheme' do
+        expect(url.scheme).to eq('http')
+      end
+    end
+  end
+end
diff --git a/spec/unit/source/csv_spec.rb b/spec/unit/source/csv_spec.rb
new file mode 100644
index 0000000..24be160
--- /dev/null
+++ b/spec/unit/source/csv_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/source/csv'
+
+describe Source::CSV do
+  describe '#each' do
+    let(:source_config) { { parth: '/rspec/test.csv' } }
+    let(:headers) { %w[record_id first_name last_name] }
+
+    include_examples 'source' do
+      before do
+        # Generate a CSV object instead of reading form the file system.
+        contents = CSV.generate do |csv|
+          csv << CSV::Row.new(headers, headers, true)
+          rows.each do |row|
+            csv << CSV::Row.new(row.keys, row.values)
+          end
+        end
+
+        csv = CSV.new(contents, headers: true)
+        allow(CSV).to receive(:open).and_return(csv)
+      end
+    end
+  end
+end
diff --git a/spec/unit/source/informix_spec.rb b/spec/unit/source/informix_spec.rb
new file mode 100644
index 0000000..557124d
--- /dev/null
+++ b/spec/unit/source/informix_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/source/informix'
+
+describe Source::Informix do
+  describe '#each' do
+    let(:source_config) do
+      {
+        table: 'rspec',
+        host: 'rspec.com',
+        database: 'rspec',
+        user: 'swhiskers',
+        password: 'clearmyrecrod',
+        schema: 'aoc',
+        security: false
+      }
+    end
+
+    include_examples 'source' do
+      before do
+        allow(Sequel).to receive(:connect).and_return(Sequel.mock(fetch: rows))
+      end
+    end
+  end
+end
diff --git a/spec/unit/source_spec.rb b/spec/unit/source_spec.rb
new file mode 100644
index 0000000..be91dd1
--- /dev/null
+++ b/spec/unit/source_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/source'
+
+RSpec.describe Source do
+  let(:config) { build(:config, sources: source_config) }
+  let(:source_config) { [{ type: 'CSV' }] }
+
+  describe '.from_config' do
+    subject(:concrete) { described_class.from_config(config.sources.first) }
+
+    context 'when a valid source type is provided' do
+      it 'returns the proper source' do
+        expect(concrete).to be_a(Source::CSV)
+      end
+    end
+
+    context 'when an invalid source type is provided' do
+      let(:source_config) { [{ type: 'Invalid' }] }
+
+      it 'raises an exception' do
+        expect { concrete }.to raise_error(Source::InvalidSource)
+      end
+    end
+  end
+end
diff --git a/spec/unit/transformation/split_value_spec.rb b/spec/unit/transformation/split_value_spec.rb
new file mode 100644
index 0000000..faee9fd
--- /dev/null
+++ b/spec/unit/transformation/split_value_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/transformation/split_value'
+
+describe Transformation::SplitValue do
+  subject(:transform) { described_class.new(transform_config) }
+
+  let(:record) { { record_id: 1, test_field: 'test,value,suffix' } }
+  let(:transform_config) { { field: 'test_field', parts: { 1 => 'new_field' } } }
+  let(:expected) { record.merge(new_field: 'value') }
+
+  describe '#transform' do
+    context 'when the delimiter is not specified' do
+      include_examples 'transform' do
+        let(:unmatched_record) { record.merge(test_field: 'test-value-suffix') }
+      end
+    end
+
+    context 'when a multi-character delimiter is specified' do
+      let(:record) { super().merge(test_field: 'test|@|value|@|suffix') }
+      let(:transform_config) { super().merge(delimiter: '|@|') }
+
+      include_examples 'transform' do
+        let(:unmatched_config) { transform_config.merge(delimiter: ':') }
+      end
+    end
+
+    context 'when a special character delimiter is specified' do
+      let(:record) { super().merge(test_field: "test\tvalue\tsuffix") }
+      let(:transform_config) { super().merge(delimiter: "\t") }
+
+      include_examples 'transform' do
+        let(:unmatched_config) { transform_config.merge(delimiter: "\n") }
+      end
+    end
+  end
+end
diff --git a/spec/unit/transformation/static_prefix_spec.rb b/spec/unit/transformation/static_prefix_spec.rb
new file mode 100644
index 0000000..0109437
--- /dev/null
+++ b/spec/unit/transformation/static_prefix_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/transformation/static_prefix'
+
+describe Transformation::StaticPrefix do
+  subject(:transform) { described_class.new(transform_config) }
+
+  let(:record) { { record_id: 1, test_field: 'test value', empty_field: '' } }
+  let(:transform_config) { { field: 'test_field', prefix: 'RSPEC' } }
+  let(:expected) { record.merge(test_field: 'RSPECtest value') }
+
+  describe '#transform' do
+    context 'when configured to apply to non-empty fields only' do
+      include_examples 'transform' do
+        let(:unmatched_config) { transform_config.merge(field: 'empty_field') }
+      end
+    end
+
+    context 'when configured to apply to empty fields as well' do
+      let(:transform_config) { super().merge(field: 'empty_field', if_not_empty: false) }
+      let(:expected) { record.merge(empty_field: 'RSPEC') }
+
+      include_examples 'transform', unmodified: false
+    end
+  end
+end
diff --git a/spec/unit/transformation/static_value_spec.rb b/spec/unit/transformation/static_value_spec.rb
new file mode 100644
index 0000000..44bf778
--- /dev/null
+++ b/spec/unit/transformation/static_value_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/transformation/static_value'
+
+describe Transformation::StaticValue do
+  subject(:transform) { described_class.new(transform_config) }
+
+  let(:record) { { record_id: 1 } }
+  let(:transform_config) { { field: 'new_field', value: 'test value' } }
+  let(:expected) { record.merge(new_field: 'test value') }
+
+  describe '#transform' do
+    context 'when the configured field does not exist' do
+      include_examples 'transform', unmodified: false
+    end
+
+    context 'when the configured field exists' do
+      let(:record) { super().merge(new_field: 'existing value') }
+
+      include_examples 'transform', unmodified: false
+    end
+  end
+end
diff --git a/spec/unit/transformation_spec.rb b/spec/unit/transformation_spec.rb
new file mode 100644
index 0000000..7231e55
--- /dev/null
+++ b/spec/unit/transformation_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/config'
+require_relative '../../lib/transformation'
+
+describe Transformation do
+  let(:config) { build(:config) }
+  let(:record) { { record_id: 1 } }
+  let(:transform_config) { [{ transform: 'StaticValue' }] }
+
+  describe '.transform' do
+    subject(:result) { described_class.transform(config, record, transform_config) }
+
+    let(:transform) { instance_double(Transformation::StaticValue) }
+    let(:transformed_record) { record }
+
+    before do
+      allow(described_class).to receive(:transform_from_config).and_return(transform)
+      allow(transform).to receive(:transform).with(record) do |record|
+        record.merge!(transformed_record)
+      end
+    end
+
+    context 'when the record is transformed' do
+      let(:transformed_record) { record.merge({ test_field: 'test_value' }) }
+
+      it 'returns the modified record' do
+        expect(result).to eq(transformed_record)
+      end
+    end
+
+    context 'when the record is not transformed' do
+      it 'returns the unmodified record' do
+        expect(result).to eq(record)
+      end
+    end
+  end
+
+  describe '.transform_from_config' do
+    subject(:concrete) { described_class.transform_from_config(transform_config.first) }
+
+    context 'when a valid transform type is provided' do
+      it 'returns the proper transformer' do
+        expect(concrete).to be_a(Transformation::StaticValue)
+      end
+    end
+
+    context 'when an invalid transform type is provided' do
+      let(:transform_config) { [{ transform: 'Invalid' }] }
+
+      it 'raises an exception' do
+        expect { concrete }.to raise_error(Transformation::InvalidTransform)
+      end
+    end
+  end
+end