├── .gitignore ├── .overcommit.yml ├── .rspec ├── .rubocop.yml ├── .test-changes.yml ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── test-changes ├── lib ├── test_changes.rb └── test_changes │ ├── argv_wrapper.rb │ ├── client.rb │ ├── config.rb │ ├── config_setup_service.rb │ ├── description.rb │ ├── error.rb │ ├── find_runner_service.rb │ ├── finding_pattern.rb │ ├── ignore_excluded_files_service.rb │ ├── inferred_config.rb │ ├── runner.rb │ ├── summary.rb │ └── version.rb ├── spec ├── fixtures │ ├── blank │ │ └── .gitkeep │ ├── rspec-rails │ │ ├── bin │ │ │ └── rspec │ │ └── config │ │ │ └── application.rb │ └── sample │ │ └── spec │ │ └── test_changes │ │ └── version_spec.rb ├── spec_helper.rb ├── test_changes │ ├── argv_wrapper_spec.rb │ ├── client_spec.rb │ ├── config_setup_service_spec.rb │ ├── config_spec.rb │ ├── find_runner_service_spec.rb │ ├── finding_pattern_spec.rb │ └── ignore_excluded_files_service_spec.rb └── test_changes_spec.rb └── test_changes.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Use this file to configure the Overcommit hooks you wish to use. This will 2 | # extend the default configuration defined in: 3 | # https://github.com/brigade/overcommit/blob/master/config/default.yml 4 | # 5 | # At the topmost level of this YAML file is a key representing type of hook 6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 7 | # customize each hook, such as whether to only run it on certain files (via 8 | # `include`), whether to only display output if it fails (via `quiet`), etc. 9 | # 10 | # For a complete list of hooks, see: 11 | # https://github.com/brigade/overcommit/tree/master/lib/overcommit/hook 12 | # 13 | # For a complete list of options that you can use to customize hooks, see: 14 | # https://github.com/brigade/overcommit#configuration 15 | # 16 | # Uncomment the following lines to make the configuration take effect. 17 | 18 | PreCommit: 19 | RuboCop: 20 | enabled: true 21 | on_warn: fail # Treat all warnings as failures 22 | 23 | TrailingWhitespace: 24 | enabled: true 25 | exclude: 26 | - '**/db/structure.sql' # Ignore trailing whitespace in generated files 27 | # 28 | #PostCheckout: 29 | # ALL: # Special hook name that customizes all hooks of this type 30 | # quiet: true # Change all post-checkout hooks to only display output on failure 31 | # 32 | # IndexTags: 33 | # enabled: true # Generate a tags file with `ctags` each time HEAD changes 34 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Refer to https://github.com/bbatsov/rubocop/blob/master/config/default.yml 2 | # for the default rubocop configuration. 3 | 4 | Documentation: 5 | Enabled: false 6 | 7 | Metrics/LineLength: 8 | Max: 89 9 | 10 | StringLiterals: 11 | Enabled: false 12 | 13 | Style/AlignParameters: 14 | Enabled: false 15 | 16 | Style/FileName: 17 | Exclude: 18 | - exe/* 19 | -------------------------------------------------------------------------------- /.test-changes.yml: -------------------------------------------------------------------------------- 1 | rspec: 2 | finding_patterns: 3 | ^lib/(.+)\.rb: spec/\1_spec.rb 4 | ^spec/(.+)_spec.rb: spec/\1_spec.rb 5 | exclude: 6 | - spec/fixtures/**/* 7 | rubocop: 8 | finding_patterns: 9 | ^(.+)\.rb: \1.rb 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: 2 | - bundle exec rspec 3 | - bundle exec rubocop 4 | rvm: 5 | - 2.0.0 6 | cache: bundler 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master 2 | 3 | ## 0.4.0 4 | 5 | * Loosen slop dependency. 6 | * Test gem against Ruby 3.1.2 and Slop 4.10.1. 7 | 8 | ## 0.3.2 9 | 10 | * Update slop to 4.2. 11 | * Add version option. 12 | 13 | ## 0.3.1 14 | 15 | ### Bug fixes 16 | 17 | * Fix: throws error if a runner doesn't have exclusion patterns. 18 | * Fix: FindingPattern should require pathname. 19 | 20 | ## 0.3.0 21 | 22 | ### Breaking changes 23 | 24 | * Reintroduced finding_patterns option in config file. See README for more info. 25 | 26 | ### New features 27 | 28 | * You can now list files that should be excluded when running a command. 29 | * You can now specify glob patterns as substitution patterns. 30 | 31 | ## 0.2.0 32 | 33 | ### Breaking changes 34 | 35 | * Command line API changed. See README and `test-changes -h` for more information. #4 36 | * Config file renamed to `test-changes.yml`. #4 37 | * Config file format changed. See README for more info. #4 38 | 39 | ### New features 40 | 41 | * You can now put multiple runners in the config file. #8 42 | 43 | ## 0.1.1 44 | 45 | * Infer configuration automatically (#2 @rstacruz). 46 | 47 | ## 0.1.0 48 | 49 | * First release. 50 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in test_changes.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 George Mendoza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Test Changes 2 | 3 | Test files that have changed since a given commit. 4 | 5 | [![Status](https://travis-ci.org/gsmendoza/test_changes.svg?branch=master)](https://travis-ci.org/gsmendoza/test_changes "See test builds") 6 | 7 | ## Requirements 8 | 9 | * [Git](https://git-scm.com) 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'test_changes', require: false 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install test_changes 26 | 27 | ## Configuration 28 | 29 | Add a `.test-changes.yml` configuration file to your repo. Example: 30 | 31 | ```yaml 32 | --- 33 | rspec: 34 | finding_patterns: 35 | ^lib/(.+)\.rb: spec/\1_spec.rb 36 | ^spec/(.+)_spec.rb: spec/\1_spec.rb 37 | exclude: 38 | - spec/fixtures/**/* 39 | rubocop: 40 | finding_patterns: 41 | ^(.+)\.rb: \1.rb 42 | ``` 43 | 44 | At the root of the file, we have the commands for running the tests. Examples: `rspec`, `zeus rspec`, `rubocop`. 45 | 46 | These are the options under each command: 47 | 48 | * `finding_patterns` - If the name of a changed file matches 49 | the regular expression, `test_changes` will test the file's matching tests. 50 | Can accept an array of tests: 51 | 52 | ```yaml 53 | rspec: 54 | finding_patterns: 55 | ^lib/test_changes\.rb: 56 | - spec/test_changes_spec.rb 57 | - spec/test_changes/*_spec.rb 58 | ``` 59 | 60 | The values can also be glob patterns. 61 | 62 | * `exclude` - Patterns of files that should be excluded from the run. 63 | 64 | ## Usage 65 | 66 | `test-changes -c [commit] -- [test_tool_arguments]` 67 | 68 | * `test_tool_arguments` - Arguments that can be passed to the test tool. 69 | 70 | * `commit` - Test change from this commit. Defaults to HEAD. 71 | 72 | See `test-changes -h` for more options. 73 | 74 | Examples: 75 | 76 | ``` 77 | test-changes 78 | test-changes -c master 79 | test-changes -r rspec -c HEAD^ -- --format=documentation 80 | ``` 81 | 82 | ## Known to work on 83 | 84 | * Ruby 3.1.2 85 | * Git 2.34.1 86 | 87 | ## Development 88 | 89 | After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle console` for an interactive prompt that will allow you to experiment. 90 | 91 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 92 | 93 | ## Contributing 94 | 95 | IMPORTANT: Please use [RuboCop](https://github.com/bbatsov/rubocop) and [Overcommit](https://github.com/brigade/overcommit) when submitting pull requests. 96 | 97 | 1. Fork it ( https://github.com/gsmendoza/test_changes/fork ) 98 | 2. Create your feature branch (`git checkout -b my-new-feature`) 99 | 3. Commit your changes (`git commit -am 'Add some feature'`) 100 | 4. Push to the branch (`git push origin my-new-feature`) 101 | 5. Create a new Pull Request 102 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "test_changes" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /exe/test-changes: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'test_changes' 4 | 5 | argv_wrapper = TestChanges::ARGVWrapper.new(ARGV) 6 | config = TestChanges::ConfigSetupService.call 7 | 8 | find_runner_service = 9 | TestChanges::FindRunnerService.new(argv_wrapper: argv_wrapper, config: config) 10 | 11 | client = TestChanges::Client.new( 12 | argv_wrapper: argv_wrapper, 13 | runner: find_runner_service.call) 14 | 15 | client.call 16 | -------------------------------------------------------------------------------- /lib/test_changes.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | require 'slop' 3 | require 'yaml' 4 | 5 | require 'test_changes/argv_wrapper' 6 | require 'test_changes/client' 7 | require 'test_changes/config' 8 | require 'test_changes/config_setup_service' 9 | require 'test_changes/description' 10 | require 'test_changes/error' 11 | require 'test_changes/finding_pattern' 12 | require 'test_changes/find_runner_service' 13 | require 'test_changes/ignore_excluded_files_service' 14 | require 'test_changes/inferred_config' 15 | require 'test_changes/runner' 16 | require 'test_changes/summary' 17 | require 'test_changes/version' 18 | -------------------------------------------------------------------------------- /lib/test_changes/argv_wrapper.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class ARGVWrapper 3 | attr_reader :argv 4 | 5 | def initialize(argv) 6 | @argv = argv 7 | end 8 | 9 | def commit 10 | slop_options[:commit] 11 | end 12 | 13 | def runner_call_options 14 | runner_call_options_delimiter_index = argv.index('--') 15 | 16 | if runner_call_options_delimiter_index 17 | argv.slice(runner_call_options_delimiter_index + 1, argv.size) 18 | else 19 | [] 20 | end 21 | end 22 | 23 | def runner_name 24 | slop_options[:runner] 25 | end 26 | 27 | def verbose? 28 | !slop_options.quiet? 29 | end 30 | 31 | private 32 | 33 | # rubocop:disable Metrics/MethodLength 34 | def slop_options 35 | Slop.parse(argv) do |o| 36 | o.banner = banner 37 | o.string '-c', '--commit', 'Git commit. Default: HEAD.', default: 'HEAD' 38 | o.boolean '-q', '--quiet', 'Do not print output. Default: false.', default: false 39 | 40 | o.string '-r', '--runner', 41 | 'The test tool to run. Default: the first runner of the config file.' 42 | 43 | o.on '-h', '--help', 'Display this help message.' do 44 | puts o 45 | exit 46 | end 47 | 48 | o.on '-v', '--version', 'Display the version.' do 49 | puts VERSION 50 | exit 51 | end 52 | end 53 | end 54 | # rubocop:enable Metrics/MethodLength 55 | 56 | def banner 57 | "Usage: test-changes [options] -- [test tool arguments]" 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/test_changes/client.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class Client 3 | def initialize(options) 4 | @argv_wrapper = options[:argv_wrapper] 5 | @runner = options[:runner] 6 | end 7 | 8 | # rubocop:disable Metrics/AbcSize 9 | def call 10 | log "Paths changed since commit #{argv_wrapper.commit}:", 11 | paths_changed_since_commit.inspect 12 | 13 | log "Matches:", matches.inspect 14 | 15 | log "Existing matches:", existing_matches.inspect 16 | 17 | return if existing_matches.empty? 18 | 19 | log "Non-excluded matches:", included_matches.inspect 20 | 21 | return if included_matches.empty? 22 | 23 | log "Test tool call:", test_tool_call 24 | system(test_tool_call) 25 | end 26 | # rubocop:enable Metrics/AbcSize 27 | 28 | private 29 | 30 | attr_reader :argv_wrapper, :runner 31 | 32 | def log(header, message) 33 | return unless argv_wrapper.verbose? 34 | 35 | puts "\n#{header}" 36 | puts message 37 | end 38 | 39 | def verbose? 40 | verbose 41 | end 42 | 43 | def paths_changed_since_commit 44 | @paths_changed_since_commit ||= 45 | `git diff --name-only --diff-filter=AMR #{argv_wrapper.commit}`.split("\n") 46 | end 47 | 48 | def test_tool_call 49 | @test_tool_call ||= [ 50 | runner.name, 51 | argv_wrapper.runner_call_options, 52 | included_matches 53 | ].flatten.compact.join(' ') 54 | end 55 | 56 | def included_matches 57 | runner.ignore_excluded_files_service.call(existing_matches) 58 | end 59 | 60 | def existing_matches 61 | @existing_matches ||= matches.select { |match| File.exist?(match) } 62 | end 63 | 64 | def matches 65 | return @matches if @matches 66 | 67 | paths = paths_changed_since_commit 68 | 69 | @matches = 70 | paths.product(runner.finding_patterns).map do |path, finding_pattern| 71 | finding_pattern.matching_paths(path) 72 | end 73 | 74 | @matches = @matches.flatten.uniq 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/test_changes/config.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class Config 3 | def initialize(config_path) 4 | @config_path = config_path 5 | end 6 | 7 | def exists? 8 | File.exist?(@config_path) 9 | end 10 | 11 | def runners 12 | config.map do |runner_name, options| 13 | finding_pattern_maps = options['finding_patterns'] 14 | 15 | Runner.new( 16 | name: runner_name, 17 | finding_patterns: FindingPattern.build(finding_pattern_maps), 18 | exclusion_patterns: options['exclude']) 19 | end 20 | end 21 | 22 | private 23 | 24 | attr_reader :config_path 25 | 26 | def config 27 | @config ||= YAML.load_file(config_path) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/test_changes/config_setup_service.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | module ConfigSetupService 3 | def self.call 4 | config_file_name = '.test-changes.yml' 5 | config = Config.new(config_file_name) 6 | 7 | return config if config.exists? 8 | 9 | if File.exist?('./config/application.rb') 10 | return use_rspec_rails('./bin/rspec') if File.exist?('./bin/rspec') 11 | return use_rspec_rails('bundle exec rspec') if File.directory?('./spec') 12 | return use_testunit_rails('bundle exec ruby -Itest') if File.directory?('./test') 13 | end 14 | 15 | fail TestChanges::Error, "No #{config_file_name} found" 16 | end 17 | 18 | private 19 | 20 | def self.use_rspec_rails(bin) 21 | runner = Runner.new( 22 | name: bin, 23 | project_type_name: 'rspec_rails', 24 | finding_patterns: FindingPattern.build( 25 | '^app/(models)/(.+).rb' => 'spec/\1/\2_spec.rb', 26 | '^app/(controller|helper|view)s/(.+).rb' => 'spec/controllers/\2_\1_spec.rb')) 27 | 28 | InferredConfig.new([runner]) 29 | end 30 | 31 | def self.use_testunit_rails(bin) 32 | runner = Runner.new( 33 | name: bin, 34 | project_type_name: 'testunit_rails', 35 | finding_patterns: FindingPattern.build( 36 | '^app/(models)/(.+).rb' => 'test/\1/\2_test.rb', 37 | '^app/(controller|helper|view)s/(.+).rb' => 'test/controllers/\2_\1_test.rb')) 38 | 39 | InferredConfig.new([runner]) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/test_changes/description.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | DESCRIPTION = 3 | 'Run only the tests affected by files changed since a given commit.'.freeze 4 | end 5 | -------------------------------------------------------------------------------- /lib/test_changes/error.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | Error = Class.new(StandardError) 3 | end 4 | -------------------------------------------------------------------------------- /lib/test_changes/find_runner_service.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class FindRunnerService 3 | def initialize(argv_wrapper: nil, config: nil) 4 | @argv_wrapper = argv_wrapper 5 | @config = config 6 | end 7 | 8 | def call 9 | return config.runners.first unless argv_wrapper.runner_name 10 | 11 | config.runners.find do |runner| 12 | runner.name == argv_wrapper.runner_name 13 | end 14 | end 15 | 16 | private 17 | 18 | attr_reader :config, :argv_wrapper 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/test_changes/finding_pattern.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | module TestChanges 4 | class FindingPattern 5 | attr_reader :matching_pattern, :substitution_patterns 6 | 7 | def initialize(options = {}) 8 | @matching_pattern = options[:matching_pattern] 9 | @substitution_patterns = options[:substitution_patterns] 10 | end 11 | 12 | def matching_paths(path) 13 | results = substitution_patterns.flat_map do |substitution_pattern| 14 | if matches?(path) 15 | substituted_pattern = path.sub(matching_pattern, substitution_pattern) 16 | Pathname.glob(substituted_pattern) 17 | end 18 | end 19 | 20 | results.compact.map(&:to_s) 21 | end 22 | 23 | def self.build(patterns) 24 | patterns.map do |pattern, substitution_patterns| 25 | new( 26 | matching_pattern: /#{pattern}/, 27 | substitution_patterns: [substitution_patterns].flatten 28 | ) 29 | end 30 | end 31 | 32 | private 33 | 34 | def matches?(path) 35 | path =~ matching_pattern 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/test_changes/ignore_excluded_files_service.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class IgnoreExcludedFilesService 3 | attr_reader :exclusion_patterns 4 | 5 | def initialize(exclusion_patterns) 6 | @exclusion_patterns = exclusion_patterns || [] 7 | end 8 | 9 | def call(paths) 10 | matching_paths = exclusion_patterns.flat_map do |pattern| 11 | Pathname.glob(pattern) 12 | end 13 | 14 | paths - matching_paths.map(&:to_s) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/test_changes/inferred_config.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | InferredConfig = Struct.new(:runners) 3 | end 4 | -------------------------------------------------------------------------------- /lib/test_changes/runner.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | class Runner 3 | def initialize( 4 | name: nil, 5 | finding_patterns: nil, 6 | exclusion_patterns: [], 7 | project_type_name: nil) 8 | 9 | @name = name 10 | @finding_patterns = finding_patterns 11 | @exclusion_patterns = exclusion_patterns 12 | @project_type_name = project_type_name 13 | end 14 | 15 | def ignore_excluded_files_service 16 | @ignore_excluded_files_service ||= 17 | IgnoreExcludedFilesService.new(exclusion_patterns) 18 | end 19 | 20 | attr_reader :exclusion_patterns, :name, :finding_patterns, :project_type_name 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/test_changes/summary.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | SUMMARY = 'Test Changes'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/test_changes/version.rb: -------------------------------------------------------------------------------- 1 | module TestChanges 2 | VERSION = "0.4.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/blank/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsmendoza/test_changes/20c2bf817f376834c57493f2f6b2c0587650a9cc/spec/fixtures/blank/.gitkeep -------------------------------------------------------------------------------- /spec/fixtures/rspec-rails/bin/rspec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsmendoza/test_changes/20c2bf817f376834c57493f2f6b2c0587650a9cc/spec/fixtures/rspec-rails/bin/rspec -------------------------------------------------------------------------------- /spec/fixtures/rspec-rails/config/application.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsmendoza/test_changes/20c2bf817f376834c57493f2f6b2c0587650a9cc/spec/fixtures/rspec-rails/config/application.rb -------------------------------------------------------------------------------- /spec/fixtures/sample/spec/test_changes/version_spec.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsmendoza/test_changes/20c2bf817f376834c57493f2f6b2c0587650a9cc/spec/fixtures/sample/spec/test_changes/version_spec.rb -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'test_changes' 3 | require 'debug' 4 | 5 | RSpec.configure do |config| 6 | config.example_status_persistence_file_path = 'tmp/examples.txt' 7 | end -------------------------------------------------------------------------------- /spec/test_changes/argv_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::ARGVWrapper do 4 | let(:default_commit) { 'HEAD' } 5 | 6 | subject(:wrapper) do 7 | described_class.new(argv) 8 | end 9 | 10 | describe "#commit" do 11 | context "where there are no arguments" do 12 | let(:argv) { [] } 13 | 14 | it "is HEAD" do 15 | expect(wrapper.commit).to eq(default_commit) 16 | end 17 | end 18 | 19 | context "where there are arguments" do 20 | let(:argv) { ['-c', commit] } 21 | let(:commit) { 'HEAD^' } 22 | 23 | it "is the last argument" do 24 | expect(wrapper.commit).to eq(commit) 25 | end 26 | end 27 | end 28 | 29 | describe "#runner_call_options" do 30 | subject(:runner_call_options) { wrapper.runner_call_options } 31 | 32 | context "where there are no arguments" do 33 | let(:argv) { [''] } 34 | 35 | it { expect(runner_call_options).to eq([]) } 36 | end 37 | 38 | context "where there are arguments after --" do 39 | let(:option) { '--format=progress' } 40 | let(:argv) { ['--', option] } 41 | 42 | it "is the first to the second to the last option" do 43 | expect(runner_call_options).to eq([option]) 44 | end 45 | end 46 | end 47 | 48 | describe "#verbose" do 49 | context "by default" do 50 | let(:argv) { [] } 51 | it { expect(wrapper.verbose?).to eq(true) } 52 | end 53 | 54 | context "where --quiet option is given" do 55 | let(:argv) { ['--quiet'] } 56 | it { expect(wrapper.verbose?).to eq(false) } 57 | end 58 | end 59 | 60 | describe '#runner_name' do 61 | context "if not provided" do 62 | let(:argv) { [] } 63 | 64 | it { expect(wrapper.runner_name).to be_nil } 65 | end 66 | 67 | context "if provided" do 68 | let(:runner_name) { 'rubocop' } 69 | let(:argv) { ['-r', runner_name] } 70 | 71 | it "should be the provided test tool command" do 72 | expect(wrapper.runner_name).to eq(runner_name) 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/test_changes/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::Client do 4 | let(:argv_wrapper) do 5 | double(:argv_wrapper, 6 | commit: commit, 7 | runner_call_options: runner_call_options, 8 | verbose?: false) 9 | end 10 | 11 | let(:ignore_excluded_files_service) do 12 | double(:ignore_excluded_files_service) 13 | end 14 | 15 | let(:finding_patterns) do 16 | [ 17 | TestChanges::FindingPattern.new( 18 | matching_pattern: %r{^lib/(.+)\.rb}, 19 | substitution_patterns: ['spec/\1_spec.rb'] 20 | ), 21 | TestChanges::FindingPattern.new( 22 | matching_pattern: %r{^spec/(.+)_spec.rb}, 23 | substitution_patterns: ['spec/\1_spec.rb'] 24 | ) 25 | ] 26 | end 27 | 28 | let(:runner_name) { 'rspec' } 29 | 30 | let(:runner) do 31 | double(:runner, 32 | finding_patterns: finding_patterns, 33 | ignore_excluded_files_service: ignore_excluded_files_service, 34 | name: runner_name) 35 | end 36 | 37 | subject(:client) do 38 | described_class.new( 39 | argv_wrapper: argv_wrapper, 40 | runner: runner 41 | ) 42 | end 43 | 44 | describe "#call" do 45 | let(:git_diff_call) do 46 | "git diff --name-only --diff-filter=AMR #{expected_commit}" 47 | end 48 | 49 | let(:changed_file_path) { 'lib/test_changes/client.rb' } 50 | 51 | let(:runner_name) { 'rspec' } 52 | 53 | let(:matching_file_path) { 'spec/test_changes/client_spec.rb' } 54 | 55 | let(:test_tool_call) do 56 | "#{runner_name} #{matching_file_path}" 57 | end 58 | 59 | context "where the commit is the only argument" do 60 | let(:runner_call_options) { [] } 61 | 62 | let(:commit) { 'HEAD^' } 63 | let(:expected_commit) { commit } 64 | 65 | it "runs the test tool on tests matching files changed since that commit" do 66 | expect(subject) 67 | .to receive(:`).with(git_diff_call) 68 | .and_return(changed_file_path) 69 | 70 | expect(subject).to receive(:system).with(test_tool_call) 71 | 72 | expect(File).to receive(:exist?).with(matching_file_path).and_return(true) 73 | 74 | expect(ignore_excluded_files_service) 75 | .to receive(:call) 76 | .with([matching_file_path]) 77 | .at_least(:once) 78 | .and_return([matching_file_path]) 79 | 80 | client.call 81 | end 82 | end 83 | 84 | context "where the arguments are the test tool options and the commit" do 85 | let(:option) { '--format=documentation' } 86 | let(:runner_call_options) { [option] } 87 | 88 | let(:commit) { 'HEAD^' } 89 | let(:expected_commit) { commit } 90 | 91 | let(:test_tool_call) do 92 | "#{runner_name} #{option} #{matching_file_path}" 93 | end 94 | 95 | it "runs the test tool on tests matching files changed since that commit" do 96 | expect(subject) 97 | .to receive(:`).with(git_diff_call) 98 | .and_return(changed_file_path) 99 | 100 | expect(subject).to receive(:system).with(test_tool_call) 101 | 102 | expect(File).to receive(:exist?).with(matching_file_path).and_return(true) 103 | 104 | expect(ignore_excluded_files_service) 105 | .to receive(:call) 106 | .with([matching_file_path]) 107 | .at_least(:once) 108 | .and_return([matching_file_path]) 109 | 110 | client.call 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/test_changes/config_setup_service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::ConfigSetupService do 4 | def fixture_path(fixture) 5 | File.join(File.expand_path('../../../spec/fixtures', __FILE__), fixture) 6 | end 7 | 8 | describe '.call' do 9 | subject(:config) do 10 | Dir.chdir(fixture_path(project_type)) { described_class.call } 11 | end 12 | 13 | let(:runner) do 14 | expect(config.runners.size).to eq(1) 15 | config.runners.first 16 | end 17 | 18 | context 'rspec-rails' do 19 | let(:project_type) { 'rspec-rails' } 20 | 21 | it 'sets the test tool command' do 22 | expect(runner.name).to eql './bin/rspec' 23 | end 24 | 25 | it 'sets finding patterns' do 26 | expect(runner.finding_patterns).to be_an Array 27 | end 28 | end 29 | 30 | context 'blank' do 31 | let(:project_type) { 'blank' } 32 | 33 | it 'raises an error' do 34 | expect { config }.to raise_error TestChanges::Error 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/test_changes/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::Config do 4 | let(:runner_name) { 'rspec' } 5 | 6 | let(:pattern_as_string) { '^lib/(.+)\.rb' } 7 | let(:pattern_as_regular_expression) { %r{^lib/(.+)\.rb} } 8 | let(:substitution_pattern) { 'spec/\1_spec.rb' } 9 | let(:substitution_patterns) { [substitution_pattern] } 10 | 11 | let(:finding_patterns_hash) do 12 | { pattern_as_string => substitution_patterns } 13 | end 14 | 15 | let(:exclusion_pattern) { 'spec/fixtures/**/*' } 16 | 17 | let(:config_contents) do 18 | { 19 | runner_name => { 20 | 'finding_patterns' => finding_patterns_hash, 21 | 'exclude' => [exclusion_pattern] 22 | } 23 | } 24 | end 25 | 26 | let(:config_path) { 'tmp/test-changes.yml' } 27 | 28 | subject(:config) { described_class.new(config_path) } 29 | 30 | let(:runner) do 31 | expect(config.runners.size).to eq(1) 32 | config.runners.first 33 | end 34 | 35 | before do 36 | FileUtils.mkdir_p 'tmp' 37 | File.write(config_path, YAML.dump(config_contents)) 38 | end 39 | 40 | describe '#runners' do 41 | let(:finding_patterns_hash) { {} } 42 | 43 | it "are the runners from the yaml file", :focus do 44 | expect(runner.name).to eq(runner_name) 45 | expect(runner.ignore_excluded_files_service.exclusion_patterns) 46 | .to eq([exclusion_pattern]) 47 | end 48 | end 49 | 50 | describe '#finding_patterns' do 51 | shared_examples "builds finding_patterns from the config" do 52 | it "builds finding_patterns from the config" do 53 | finding_pattern = runner.finding_patterns.first 54 | 55 | expect(finding_pattern).to be_a(TestChanges::FindingPattern) 56 | 57 | expect(finding_pattern.matching_pattern).to eq(pattern_as_regular_expression) 58 | expect(finding_pattern.substitution_patterns).to eq([substitution_pattern]) 59 | end 60 | end 61 | 62 | let(:finding_patterns_hash) do 63 | { pattern_as_string => substitution_patterns } 64 | end 65 | 66 | context "where the substitution_patterns is an array" do 67 | let(:substitution_patterns) { [substitution_pattern] } 68 | 69 | include_examples "builds finding_patterns from the config" 70 | end 71 | 72 | context "where the substitution_patterns is a single pattern" do 73 | let(:substitution_patterns) { substitution_pattern } 74 | 75 | include_examples "builds finding_patterns from the config" 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/test_changes/find_runner_service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::FindRunnerService do 4 | let(:default_runner_name) { 'rspec' } 5 | 6 | let(:finding_patterns) { [double(:finding_pattern)] } 7 | 8 | let(:config_runners) do 9 | [ 10 | double(:runner, 11 | finding_patterns: finding_patterns, 12 | name: default_runner_name), 13 | double(:runner, 14 | finding_patterns: finding_patterns, 15 | name: provided_runner_name) 16 | ] 17 | end 18 | 19 | let(:config) do 20 | double(:config, runners: config_runners) 21 | end 22 | 23 | let(:provided_runner_name) { nil } 24 | 25 | let(:argv_wrapper) do 26 | double(:argv_wrapper, 27 | runner_name: provided_runner_name) 28 | end 29 | 30 | subject(:service) do 31 | described_class.new( 32 | argv_wrapper: argv_wrapper, 33 | config: config) 34 | end 35 | 36 | let(:runner) { service.call } 37 | 38 | describe "#call" do 39 | context "where the user did not provide a runner" do 40 | let(:provided_runner_name) { nil } 41 | 42 | it "is the first runner from the config" do 43 | expect(runner.name).to eq(default_runner_name) 44 | end 45 | end 46 | 47 | context "where the user provided a runner" do 48 | let(:provided_runner_name) { 'rubocop' } 49 | 50 | before do 51 | expect(provided_runner_name).to_not eq(default_runner_name) 52 | end 53 | 54 | it "is the runner from the config matching the one provided" do 55 | expect(runner.name).to eq(provided_runner_name) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/test_changes/finding_pattern_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::FindingPattern do 4 | let(:matching_pattern) { %r{^lib/test_changes\.rb} } 5 | 6 | subject(:finding_pattern) do 7 | described_class.new( 8 | matching_pattern: matching_pattern, 9 | substitution_patterns: substitution_patterns 10 | ) 11 | end 12 | 13 | describe "#matching_paths(path)" do 14 | let(:path) { 'lib/test_changes.rb' } 15 | 16 | let(:expected_match) do 17 | 'spec/fixtures/sample/spec/test_changes/version_spec.rb' 18 | end 19 | 20 | context "where a substitution pattern is a glob pattern" do 21 | let(:substitution_pattern) do 22 | 'spec/fixtures/sample/spec/test_changes/*_spec.rb' 23 | end 24 | 25 | let(:substitution_patterns) { [substitution_pattern] } 26 | 27 | it "returns paths that match both the glob pattern and the given path" do 28 | results = finding_pattern.matching_paths(path) 29 | expect(results).to include(expected_match) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/test_changes/ignore_excluded_files_service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges::IgnoreExcludedFilesService do 4 | let(:matching_path) do 5 | 'spec/fixtures/sample/spec/test_changes/version_spec.rb' 6 | end 7 | 8 | let(:non_matching_path) { 'lib/test_changes.rb' } 9 | 10 | let(:paths) { [matching_path, non_matching_path] } 11 | 12 | let(:exclusion_pattern) { 'spec/fixtures/**/*' } 13 | let(:exclusion_patterns) { [exclusion_pattern] } 14 | 15 | let(:service) do 16 | described_class.new(exclusion_patterns) 17 | end 18 | 19 | describe '#initialize' do 20 | context "where exclusion_patterns is nil" do 21 | let(:exclusion_patterns) { nil } 22 | 23 | it "sets it to empty array" do 24 | expect(service.exclusion_patterns).to eq([]) 25 | end 26 | end 27 | end 28 | 29 | describe '#call(files)' do 30 | context "where a file matches an exclusion pattern" do 31 | let(:matching_path) do 32 | 'spec/fixtures/sample/spec/test_changes/version_spec.rb' 33 | end 34 | 35 | it "is rejected" do 36 | results = service.call(paths) 37 | expect(results).to_not include(matching_path) 38 | end 39 | end 40 | 41 | context "where a file does not match an exclusion pattern" do 42 | let(:non_matching_path) { 'lib/test_changes.rb' } 43 | 44 | it "is accepted" do 45 | results = service.call(paths) 46 | expect(results).to include(non_matching_path) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/test_changes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TestChanges do 4 | it 'has a version number' do 5 | expect(TestChanges::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test_changes.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | require 'test_changes/description' 6 | require 'test_changes/summary' 7 | require 'test_changes/version' 8 | 9 | Gem::Specification.new do |spec| 10 | spec.name = "test_changes" 11 | spec.version = TestChanges::VERSION 12 | spec.authors = ["George Mendoza"] 13 | spec.email = ["gsmendoza@gmail.com"] 14 | 15 | spec.summary = TestChanges::SUMMARY 16 | spec.description = TestChanges::DESCRIPTION 17 | 18 | spec.homepage = "https://github.com/gsmendoza/test_changes" 19 | spec.license = "MIT" 20 | 21 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 22 | f.match(%r{^(test|spec|features)/}) 23 | end 24 | 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_dependency "slop", '~> 4.10' 30 | 31 | spec.add_development_dependency 'debug' 32 | spec.add_development_dependency 'rake', '~> 13.0.1', '>= 12.3.3' 33 | spec.add_development_dependency 'rspec', '~> 3.9.0' 34 | spec.add_development_dependency 'rubocop', '~> 0.80.1' 35 | end 36 | --------------------------------------------------------------------------------