├── .gitignore ├── .travis.yml ├── .yardopts ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── ttnt.rb └── ttnt │ ├── anchor.rb │ ├── internals.rb │ ├── metadata.rb │ ├── storage.rb │ ├── test_selector.rb │ ├── test_to_code_mapping.rb │ ├── testtask.rb │ └── version.rb ├── test ├── fixtures │ ├── addition_among_comments │ │ ├── Rakefile │ │ ├── double.rb │ │ └── double_test.rb │ ├── fizzbuzz │ │ ├── Rakefile │ │ ├── buzz_test.rb │ │ ├── fizz_test.rb │ │ └── fizzbuzz.rb │ └── fizzbuzz_multicode │ │ ├── Rakefile │ │ ├── all_test.rb │ │ ├── buzz_detectable.rb │ │ ├── buzz_test.rb │ │ ├── fizz_detectable.rb │ │ ├── fizz_test.rb │ │ ├── fizzbuzz.rb │ │ ├── fizzbuzz_detectable.rb │ │ └── fizzbuzz_test.rb ├── helpers │ ├── git_helper.rb │ └── rake_helper.rb ├── integration_test.rb ├── metadata_test.rb ├── storage_test.rb ├── test_helper.rb ├── test_selector_test.rb ├── test_to_code_mapping_test.rb ├── testtask_test.rb └── ttnt_test.rb └── ttnt.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.2 4 | before_install: gem install bundler -v 1.10.2 5 | install: 6 | - ./bin/setup 7 | script: 8 | - bundle exec rake test 9 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --private 3 | --list-undoc 4 | --readme README.md 5 | -------------------------------------------------------------------------------- /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, ethnicity, 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 ttnt.gemspec 4 | gemspec 5 | 6 | gem "coveralls", require: false 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Genki Sugimoto 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 | **This project is discontinued because of technical difficulty, and left here only for reference.** 2 | 3 | If I were to start this again from scratch, I'd store test-to-code mapping not in git repo in each project, but in some external storage in order not to clutter project git history. 4 | 5 | # TTNT: Test This, Not That! 6 | 7 | [![Build Status](https://travis-ci.org/Genki-S/ttnt.svg?branch=master)](https://travis-ci.org/Genki-S/ttnt) 8 | [![Coverage Status](https://coveralls.io/repos/Genki-S/ttnt/badge.svg?branch=master)](https://coveralls.io/r/Genki-S/ttnt?branch=master) 9 | [![Code Climate](https://codeclimate.com/github/Genki-S/ttnt/badges/gpa.svg)](https://codeclimate.com/github/Genki-S/ttnt) 10 | [![Dependency Status](https://gemnasium.com/Genki-S/ttnt.svg)](https://gemnasium.com/Genki-S/ttnt) 11 | 12 | Stop running tests which are clearly not affected by the change you introduced in your commit! 13 | 14 | Started as a [Google Summer of Code 2015](http://www.google-melange.com/gsoc/homepage/google/gsoc2015) project with mentoring organization [Ruby on Rails](http://rubyonrails.org/), with idea based on [Aaron Patterson](https://twitter.com/tenderlove)'s article ["Predicting Test Failures"](http://tenderlovemaking.com/2015/02/13/predicting-test-failues.html). 15 | 16 | ## Goal of this project 17 | 18 | [rails/rails](https://github.com/rails/rails) has a problem that CI builds take hours to finish. This project aims to solve that problem by making it possible to run only tests related to changes introduced in target commits/branches/PRs. 19 | 20 | ## Terminology 21 | 22 | - test-to-code mapping 23 | - mapping which maps test (file name) to code (file name and line number) executed on that test run for a given commit 24 | - this will be used to determine tests that are affected by changes in code 25 | - base commit 26 | - the commit to which the tests you should run will be calculated (e.g. the latest commit of master branch) 27 | - this commit should have test-to-code mapping 28 | - target commit 29 | - the commit on which you want to select tests you should run (e.g. HEAD of your feature branch) 30 | - this commit does not have to have test-to-code mapping 31 | 32 | ## Current Status 33 | 34 | This project is still in an early stage and we are experimenting the best approach to solve the problem. 35 | 36 | Currently, this program does: 37 | 38 | - Generate test-to-code mapping with `$ rake ttnt:test:anchor` 39 | - Select tests related to the change between base commit and current HEAD, and run the selected tests 40 | 41 | ## Installation 42 | 43 | Add this line to your application's Gemfile: 44 | 45 | ```ruby 46 | gem 'ttnt' 47 | ``` 48 | 49 | And then execute: 50 | 51 | $ bundle 52 | 53 | Or install it yourself as: 54 | 55 | $ gem install ttnt 56 | 57 | ### Define Rake tasks 58 | 59 | TTNT allows you to define its tasks according to an existing `Rake::TestTask` object like: 60 | 61 | ```ruby 62 | require 'rake/testtask' 63 | require 'ttnt/testtask' 64 | 65 | t = Rake::TestTask.new do |t| 66 | t.libs << 'test' 67 | t.name = 'task_name' 68 | end 69 | 70 | TTNT::TestTask.new(t) 71 | ``` 72 | 73 | This will define 2 tasks: `ttnt:task_name:anchor` and `ttnt:task_name:run`. Usage for those tasks are described later in this document. 74 | 75 | You can also instantiate a new `TTNT::TestTask` object and specify certain options like: 76 | 77 | ```ruby 78 | require 'ttnt/testtask' 79 | 80 | TTNT::TestTask.new do |t| 81 | t.code_files = FileList['lib/**/*.rb'] - FileList['lib/vendor/**/*.rb'] 82 | t.test_files = 'test/**/*_test.rb' 83 | end 84 | ``` 85 | 86 | You can specify the same options as `Rake::TestTask`. 87 | Additionally, there is an option which is specific to TTNT: 88 | 89 | - `code_files` 90 | - Specifies code files TTNT uses to select tests. Changes in files not listed here do not affect the test selection. Defaults to all files under the directory `Rakefile` resides. 91 | 92 | ## Requirements 93 | 94 | Developed and only tested under ruby version 2.2.2. 95 | 96 | ## Usage 97 | 98 | ### Produce test-to-code mapping for a given commit 99 | 100 | If you defined TTNT rake task as described above, you can run following command to produce test-to-code mapping: 101 | 102 | ```sh 103 | $ rake ttnt:my_test_name:anchor 104 | ``` 105 | 106 | ### Select tests 107 | 108 | If you defined TTNT rake task as described above, you can run following command to run selected tests. 109 | 110 | ```sh 111 | $ rake ttnt:my_test_name:run 112 | ``` 113 | 114 | #### Options 115 | 116 | You can run test files one by one by setting `ISOLATED` environment variable: 117 | 118 | ``` 119 | $ rake ttnt:my_test_name:run ISOLATED=1 120 | ``` 121 | 122 | With isolated option, you can set `FAIL_FAST` environment variable to stop running successive tests after a test has failed: 123 | 124 | ``` 125 | $ rake ttnt:my_test_name:run ISOLATED=1 FAIL_FAST=1 126 | ``` 127 | 128 | ## Current Limitations 129 | 130 | - Test selection algorithm is not perfect yet (it may produce false-positives and false-negatives) 131 | - Only supports git 132 | - Only supports MiniTest 133 | - Only select test files, not fine-grained test cases 134 | - And a lot more! 135 | 136 | This gem can only produce test-to-code mapping "from a single test file to code lines executed" 137 | (not fine-grained mapping "from a single test **case** to code lines executed"). 138 | This is due to the way Ruby's coverage library works. Details are covered in [my proposal](https://github.com/Genki-S/gsoc2015/blob/master/proposal.md#2-run-each-test-case-from-scratch-requiring-all-files-for-every-run). 139 | 140 | ## Development 141 | 142 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 143 | 144 | 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`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 145 | 146 | ## Contributing 147 | 148 | Bug reports and pull requests are welcome on GitHub at https://github.com/Genki-S/ttnt. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://www.contributor-covenant.org) code of conduct. 149 | 150 | I really :heart: getting interesting ideas, so please don't hesitate to [open a issue](https://github.com/Genki-S/ttnt/issues/new) to share your ideas for me! Any comment will be valuable especially in this early development stage. I am collecting interesting ideas which I cannot start working on soon [here at Trello](https://trello.com/b/z232DXnq/ttnt). 151 | 152 | ## License 153 | 154 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 155 | 156 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "ttnt/testtask" 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.warning = true 9 | t.test_files = FileList['test/**/*_test.rb'] - FileList['test/fixtures/**/*_test.rb'] 10 | TTNT::TestTask.new(t) 11 | end 12 | 13 | task :default => :test 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "ttnt" 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 | -------------------------------------------------------------------------------- /lib/ttnt.rb: -------------------------------------------------------------------------------- 1 | require "ttnt/version" 2 | 3 | # Test This, Not That! 4 | # 5 | # See {file:README.md} for more details. 6 | module TTNT 7 | # Your code goes here... 8 | end 9 | -------------------------------------------------------------------------------- /lib/ttnt/anchor.rb: -------------------------------------------------------------------------------- 1 | require 'coverage' 2 | require 'ttnt/test_to_code_mapping' 3 | require 'ttnt/metadata' 4 | require 'rugged' 5 | 6 | test_file = $0 7 | 8 | Coverage.start 9 | 10 | at_exit do 11 | # Use current HEAD 12 | repo = Rugged::Repository.discover('.') 13 | sha = repo.head.target_id 14 | mapping = TTNT::TestToCodeMapping.new(repo) 15 | mapping.append_from_coverage(test_file, Coverage.result) 16 | mapping.write! 17 | 18 | metadata = TTNT::MetaData.new(repo) 19 | metadata['anchored_commit'] = sha 20 | metadata.write! 21 | end 22 | -------------------------------------------------------------------------------- /lib/ttnt/internals.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | module TTNT 4 | class << self 5 | def root_dir 6 | @@root_dir ||= Rake.application.find_rakefile_location[1] 7 | end 8 | 9 | def root_dir=(dir) 10 | @@root_dir = dir 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/ttnt/metadata.rb: -------------------------------------------------------------------------------- 1 | require 'ttnt/storage' 2 | 3 | module TTNT 4 | class MetaData 5 | STORAGE_SECTION = 'meta' 6 | 7 | # @param repo [Rugged::Repository] 8 | # @param sha [String] sha of commit which metadata is read from. 9 | # nil means to read from current working tree. See {Storage} for more. 10 | def initialize(repo, sha = nil) 11 | @storage = Storage.new(repo, sha) 12 | read! 13 | end 14 | 15 | def [](name) 16 | @data[name] 17 | end 18 | 19 | def []=(name, value) 20 | @data[name] = value 21 | end 22 | 23 | def read! 24 | @data = @storage.read(STORAGE_SECTION) 25 | end 26 | 27 | def write! 28 | @storage.write!(STORAGE_SECTION, @data) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ttnt/storage.rb: -------------------------------------------------------------------------------- 1 | require 'ttnt/internals' 2 | 3 | module TTNT 4 | # A utility class to store TTNT data such as test-to-code mapping and metadata. 5 | class Storage 6 | # Initialize the storage from given repo and sha. This reads contents from 7 | # a `.ttnt` file. When sha is not nil, contents of the file on that commit 8 | # is read. Data can be written only when sha is nil (written to current 9 | # working tree). 10 | # 11 | # @param repo [Rugged::Repository] 12 | # @param sha [String] sha of the commit which data should be read from. 13 | # nil means reading from/writing to current working tree. 14 | def initialize(repo, sha = nil) 15 | @repo = repo 16 | @sha = sha 17 | end 18 | 19 | # Read data from the storage in the given section. 20 | # 21 | # @param section [String] 22 | # @return [Hash] 23 | def read(section) 24 | str = read_storage_content 25 | 26 | if str.length > 0 27 | JSON.parse(str)[section] || {} 28 | else 29 | {} 30 | end 31 | end 32 | 33 | # Write value to the given section in the storage. 34 | # Locks the file so that concurrent write does not occur. 35 | # 36 | # @param section [String] 37 | # @param value [Hash] 38 | def write!(section, value) 39 | raise 'Data cannot be written to the storage back in git history' unless @sha.nil? 40 | File.open(filename, File::RDWR|File::CREAT, 0644) do |f| 41 | f.flock(File::LOCK_EX) 42 | str = f.read 43 | data = str.length > 0 ? JSON.parse(str) : {} 44 | data[section] = value 45 | f.rewind 46 | f.write(data.to_json) 47 | f.flush 48 | f.truncate(f.pos) 49 | end 50 | end 51 | 52 | private 53 | 54 | def filename 55 | "#{TTNT.root_dir}/.ttnt" 56 | end 57 | 58 | def filename_from_repository_root 59 | filename.gsub(@repo.workdir, '') 60 | end 61 | 62 | def storage_file_oid 63 | tree = @repo.lookup(@sha).tree 64 | paths = filename_from_repository_root.split(File::SEPARATOR) 65 | dirs, filename = paths[0...-1], paths[-1] 66 | dirs.each do |dir| 67 | obj = tree[dir] 68 | return nil unless obj 69 | tree = @repo.lookup(obj[:oid]) 70 | end 71 | obj = tree[filename] 72 | return nil unless obj 73 | obj[:oid] 74 | end 75 | 76 | def read_storage_content 77 | if @sha 78 | if oid = storage_file_oid 79 | @repo.lookup(oid).content 80 | else 81 | '' # Storage file is not committed for the commit of given sha 82 | end 83 | else 84 | File.exist?(filename) ? File.read(filename) : '' 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/ttnt/test_selector.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | require 'rugged' 3 | require 'ttnt/metadata' 4 | require 'ttnt/test_to_code_mapping' 5 | 6 | module TTNT 7 | # Select tests using Git information and {TestToCodeMapping}. 8 | class TestSelector 9 | 10 | attr_reader :tests 11 | 12 | # @param repo [Rugged::Reposiotry] repository of the project 13 | # @param target_sha [String] sha of the target object 14 | # (nil means to target current working tree) 15 | # @param test_files [#include?] candidate test files 16 | def initialize(repo, target_sha, test_files) 17 | @repo = repo 18 | storage_src_sha = target_sha ? target_sha : @repo.head.target_id 19 | @metadata = MetaData.new(repo, storage_src_sha) 20 | @target_obj = @repo.lookup(target_sha) if target_sha 21 | 22 | # Base should be the commit `ttnt:anchor` has run on. 23 | # NOT the one test-to-code mapping was commited to. 24 | @base_obj = find_anchored_commit 25 | 26 | @test_files = test_files 27 | end 28 | 29 | # Select tests using differences in anchored commit and target commit 30 | # (or current working tree) and {TestToCodeMapping}. 31 | # 32 | # @return [Set] a set of tests that might be affected by changes in base_sha...target_sha 33 | def select_tests! 34 | # select all tests if anchored commit does not exist 35 | return Set.new(@test_files) unless @base_obj 36 | 37 | @tests ||= Set.new 38 | 39 | opts = { 40 | include_untracked: true, 41 | recurse_untracked_dirs: true 42 | } 43 | diff = defined?(@target_obj) ? @base_obj.diff(@target_obj, opts) : @base_obj.diff_workdir(opts) 44 | 45 | diff.each_patch do |patch| 46 | file = patch.delta.old_file[:path] 47 | if test_file?(file) 48 | @tests << file 49 | else 50 | select_tests_from_patch(patch) 51 | end 52 | end 53 | @tests.delete(nil) 54 | end 55 | 56 | private 57 | 58 | def mapping 59 | @mapping ||= begin 60 | sha = defined?(@target_obj) ? @target_obj.oid : @repo.head.target_id 61 | TTNT::TestToCodeMapping.new(@repo, sha) 62 | end 63 | end 64 | 65 | # Select tests which are affected by the change of given patch. 66 | # 67 | # @param patch [Rugged::Patch] 68 | # @return [Set] set of selected tests 69 | def select_tests_from_patch(patch) 70 | target_lines = Set.new 71 | file = patch.delta.old_file[:path] 72 | prev_line = nil 73 | 74 | patch.each_hunk do |hunk| 75 | hunk.each_line do |line| 76 | case line.line_origin 77 | when :addition 78 | if prev_line && !prev_line.addition? 79 | target_lines << prev_line.old_lineno 80 | elsif prev_line.nil? 81 | target_lines << hunk.old_start 82 | end 83 | when :deletion 84 | target_lines << line.old_lineno 85 | end 86 | 87 | prev_line = line 88 | end 89 | end 90 | 91 | target_lines.each do |line| 92 | @tests += mapping.get_tests(file: file, lineno: line) 93 | end 94 | end 95 | 96 | # Find the commit `ttnt:anchor` has been run on. 97 | def find_anchored_commit 98 | if @metadata['anchored_commit'] 99 | @repo.lookup(@metadata['anchored_commit']) 100 | else 101 | nil 102 | end 103 | end 104 | 105 | # Check if the given file is a test file. 106 | # 107 | # @param filename [String] 108 | def test_file?(filename) 109 | @test_files.include?(filename) 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/ttnt/test_to_code_mapping.rb: -------------------------------------------------------------------------------- 1 | require 'ttnt/internals' 2 | require 'ttnt/storage' 3 | require 'rugged' 4 | require 'json' 5 | require 'set' 6 | 7 | module TTNT 8 | # Mapping from test file to executed code (i.e. coverage without execution count). 9 | # 10 | # Terminologies: 11 | # spectra: { filename => [line, numbers, executed], ... } 12 | # mapping: { test_file => spectra } 13 | class TestToCodeMapping 14 | STORAGE_SECTION = 'mapping' 15 | 16 | attr_reader :mapping 17 | 18 | # @param repo [Rugged::Reposiotry] repository to save test-to-code mapping 19 | # @param sha [String] sha of commit from which mapping is read. 20 | # nil means to read from current working tree. See {Storage} for more. 21 | def initialize(repo, sha = nil) 22 | @repo = repo || raise('Not in a git repository') 23 | @storage = Storage.new(repo, sha) 24 | read! 25 | end 26 | 27 | # Append the new mapping to test-to-code mapping file. 28 | # 29 | # @param test [String] test file for which the coverage data is produced 30 | # @param coverage [Hash] coverage data generated using `Coverage.start` and `Coverage.result` 31 | # @return [void] 32 | def append_from_coverage(test, coverage) 33 | spectra = normalize_paths(select_project_files(spectra_from_coverage(coverage))) 34 | @mapping[test] = spectra 35 | end 36 | 37 | # Read test-to-code mapping from storage. 38 | def read! 39 | @mapping = @storage.read(STORAGE_SECTION) 40 | end 41 | 42 | # Write test-to-code mapping to storage. 43 | def write! 44 | @storage.write!(STORAGE_SECTION, @mapping) 45 | end 46 | 47 | # Get tests affected from change of file `file` at line number `lineno` 48 | # 49 | # @param file [String] file name which might have effects on some tests 50 | # @param lineno [Integer] line number in the file which might have effects on some tests 51 | # @return [Set] a set of test files which might be affected by the change in file at lineno 52 | def get_tests(file:, lineno:) 53 | tests = Set.new 54 | @mapping.each do |test, spectra| 55 | lines = spectra[file] 56 | next unless lines 57 | topmost = lines.first 58 | downmost = lines.last 59 | if topmost <= lineno && lineno <= downmost 60 | tests << test 61 | end 62 | end 63 | tests 64 | end 65 | 66 | # Select (filter) code files from mapping by given file names. 67 | # 68 | # @param code_files [#include?] code file names to filter 69 | def select_code_files!(code_files) 70 | @mapping.map do |test, spectra| 71 | spectra.select! do |code, lines| 72 | code_files.include?(code) 73 | end 74 | end 75 | end 76 | 77 | private 78 | 79 | # Convert absolute path to relative path from the project (Git repository) root. 80 | # 81 | # @param file [String] file name (absolute path) 82 | # @return [String] normalized file path 83 | def normalized_path(file) 84 | File.expand_path(file).sub("#{TTNT.root_dir}/", '') 85 | end 86 | 87 | # Normalize all file names in a spectra. 88 | # 89 | # @param spectra [Hash] spectra data 90 | # @return [Hash] spectra whose keys (file names) are normalized 91 | def normalize_paths(spectra) 92 | spectra.map do |filename, lines| 93 | [normalized_path(filename), lines] 94 | end.to_h 95 | end 96 | 97 | # Filter out the files outside of the target project using file path. 98 | # 99 | # @param spectra [Hash] spectra data 100 | # @return [Hash] spectra with only files inside the target project 101 | def select_project_files(spectra) 102 | spectra.select do |filename, lines| 103 | filename.start_with?(TTNT.root_dir) 104 | end 105 | end 106 | 107 | # Generate spectra data from Ruby coverage library's data 108 | # 109 | # @param cov [Hash] coverage data generated using `Coverage.result` 110 | # @return [Hash] spectra data 111 | def spectra_from_coverage(cov) 112 | spectra = Hash.new { |h, k| h[k] = [] } 113 | cov.each do |filename, executions| 114 | executions.each_with_index do |execution, i| 115 | next if execution.nil? || execution == 0 116 | spectra[filename] << i + 1 117 | end 118 | end 119 | spectra 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/ttnt/testtask.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | require 'rake/testtask' 3 | require 'ttnt/test_selector' 4 | 5 | module TTNT 6 | # TTNT version of Rake::TestTask. 7 | # 8 | # You can use the configuration from a Rake::TestTask to minimize user 9 | # configuration. 10 | # 11 | # Defines TTNT related rake tasks when instantiated. 12 | class TestTask 13 | include Rake::DSL 14 | 15 | attr_accessor :rake_testtask 16 | attr_reader :code_files, :test_files 17 | 18 | # Create an instance of TTNT::TestTask and define TTNT rake tasks. 19 | # 20 | # @param rake_testtask [Rake::TestTask] an instance of Rake::TestTask 21 | # after user configuration is done 22 | def initialize(rake_testtask = nil) 23 | @rake_testtask = rake_testtask || Rake::TestTask.new 24 | 25 | # There's no `test_files` method so we can't delegate it 26 | # to the internal task through `method_missing`. 27 | @test_files = @rake_testtask.instance_variable_get('@test_files') 28 | 29 | yield self if block_given? 30 | 31 | target = (name == :test) ? '' : " for #{name}" 32 | @anchor_description = "Generate test-to-code mapping#{target}" 33 | @run_description = "Run selected tests#{target}" 34 | define_tasks 35 | end 36 | 37 | # Delegate missing methods to the internal task 38 | # so we can override the defaults during the 39 | # block execution. 40 | def method_missing(method, *args, &block) 41 | @rake_testtask.public_send(method, *args, &block) 42 | end 43 | 44 | def code_files=(files) 45 | @code_files = files.kind_of?(String) ? FileList[files] : files 46 | end 47 | 48 | def test_files=(files) 49 | @test_files = files.kind_of?(String) ? FileList[files] : files 50 | end 51 | 52 | # Returns array of test file names. 53 | # Unlike Rake::TestTask#file_list, patterns are expanded. 54 | def expanded_file_list 55 | test_files = Rake::FileList[pattern].compact 56 | test_files += @test_files.to_a if @test_files 57 | test_files 58 | end 59 | 60 | private 61 | 62 | # Git repository discovered from current directory 63 | # 64 | # @return [Rugged::Reposiotry] 65 | def repo 66 | @repo ||= Rugged::Repository.discover('.') 67 | end 68 | 69 | # Define TTNT tasks under namespace 'ttnt:TESTNAME' 70 | # 71 | # @return [void] 72 | def define_tasks 73 | # Task definitions are taken from Rake::TestTask 74 | # https://github.com/ruby/rake/blob/e644af3/lib/rake/testtask.rb#L98-L112 75 | namespace :ttnt do 76 | namespace name do 77 | define_run_task 78 | define_anchor_task 79 | end 80 | end 81 | end 82 | 83 | # Define a task which runs only tests which might have been affected from 84 | # changes between anchored commit and TARGET_SHA. 85 | # 86 | # TARGET_SHA can be specified as an environment variable (defaults to HEAD). 87 | # 88 | # @return [void] 89 | def define_run_task 90 | desc @run_description 91 | task 'run' do 92 | target_sha = ENV['TARGET_SHA'] 93 | ts = TTNT::TestSelector.new(repo, target_sha, expanded_file_list) 94 | tests = ts.select_tests! 95 | 96 | if tests.empty? 97 | STDERR.puts 'No test selected.' 98 | else 99 | if ENV['ISOLATED'] 100 | tests.each do |test| 101 | args = "#{ruby_opts_string} #{test} #{option_list}" 102 | run_ruby args 103 | break if @failed && ENV['FAIL_FAST'] 104 | end 105 | else 106 | args = 107 | "#{ruby_opts_string} #{run_code} " + 108 | "#{tests.to_a.join(' ')} #{option_list}" 109 | run_ruby args 110 | end 111 | end 112 | end 113 | end 114 | 115 | # Define a task which runs tests file by file, and generate and save 116 | # test-to-code mapping. 117 | # 118 | # @return [void] 119 | def define_anchor_task 120 | desc @anchor_description 121 | task 'anchor' do 122 | # In order to make it possible to stop coverage services like Coveralls 123 | # which interferes with ttnt/anchor because both use coverage library. 124 | # See test/test_helper.rb 125 | ENV['ANCHOR_TASK'] = '1' 126 | 127 | Rake::FileUtilsExt.verbose(verbose) do 128 | # Make it possible to require files in this gem 129 | gem_root = File.expand_path('../..', __FILE__) 130 | args = 131 | "-I#{gem_root} -r ttnt/anchor " + 132 | "#{ruby_opts_string}" 133 | 134 | expanded_file_list.each do |test_file| 135 | run_ruby "#{args} #{test_file}" 136 | end 137 | end 138 | 139 | if @code_files 140 | mapping = TestToCodeMapping.new(repo) 141 | mapping.select_code_files!(@code_files) 142 | mapping.write! 143 | end 144 | end 145 | end 146 | 147 | # Run ruby process with given args 148 | # 149 | # @param args [String] argument to pass to ruby 150 | def run_ruby(args) 151 | ruby "#{args}" do |ok, status| 152 | @failed = true if !ok 153 | if !ok && status.respond_to?(:signaled?) && status.signaled? 154 | raise SignalException.new(status.termsig) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/ttnt/version.rb: -------------------------------------------------------------------------------- 1 | module TTNT 2 | VERSION = "0.0.4" 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/addition_among_comments/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require 'ttnt/testtask' 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.pattern = '*_test.rb' 6 | TTNT::TestTask.new(t) 7 | end 8 | 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /test/fixtures/addition_among_comments/double.rb: -------------------------------------------------------------------------------- 1 | def double(n) 2 | # Lorem 3 | # ipsum 4 | # dolor 5 | n * 2 6 | end 7 | -------------------------------------------------------------------------------- /test/fixtures/addition_among_comments/double_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './double' 3 | 4 | class TestFizz < Minitest::Test 5 | def test_double 6 | assert_equal 4, double(2) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require 'ttnt/testtask' 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.pattern = '**/*_test.rb' 6 | TTNT::TestTask.new(t) 7 | end 8 | 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz/buzz_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestBuzz < Minitest::Test 5 | def test_buzz 6 | assert_equal "buzz", fizzbuzz_convert(5) 7 | assert_equal "buzz", fizzbuzz_convert(10) 8 | assert_equal "buzz", fizzbuzz_convert(20) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz/fizz_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestFizz < Minitest::Test 5 | def test_fizz 6 | assert_equal "fizz", fizzbuzz_convert(3) 7 | assert_equal "fizz", fizzbuzz_convert(6) 8 | assert_equal "fizz", fizzbuzz_convert(9) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz/fizzbuzz.rb: -------------------------------------------------------------------------------- 1 | # Padding 2 | def fizzbuzz_convert(n) 3 | if n % 15 == 0 4 | "fizzbuzz" 5 | elsif n % 5 == 0 6 | "buzz" 7 | elsif n % 3 == 0 8 | "fizz" 9 | else 10 | n 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/Rakefile: -------------------------------------------------------------------------------- 1 | require 'ttnt/testtask' 2 | 3 | TTNT::TestTask.new do |t| 4 | t.test_files = ['fizz_test.rb', 'buzz_test.rb', 'fizzbuzz_test.rb'] 5 | t.code_files = ['fizz_detectable.rb', 'buzz_detectable.rb', 'fizzbuzz_detectable.rb'] 6 | end 7 | 8 | task :default => :test 9 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/all_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestAll < Minitest::Test 5 | def test_all 6 | fb = FizzBuzz.new 7 | assert_equal "fizz", fb.convert(3) 8 | assert_equal "buzz", fb.convert(5) 9 | assert_equal "fizzbuzz", fb.convert(15) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/buzz_detectable.rb: -------------------------------------------------------------------------------- 1 | module BuzzDetectable 2 | 3 | module_function 4 | 5 | def buzz?(n) 6 | n % 5 == 0 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/buzz_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestBuzz < Minitest::Test 5 | def test_buzz 6 | fb = FizzBuzz.new 7 | assert_equal "buzz", fb.convert(5) 8 | assert_equal "buzz", fb.convert(10) 9 | assert_equal "buzz", fb.convert(20) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/fizz_detectable.rb: -------------------------------------------------------------------------------- 1 | module FizzDetectable 2 | 3 | module_function 4 | 5 | def fizz?(n) 6 | n % 3 == 0 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/fizz_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestFizz < Minitest::Test 5 | def test_fizz 6 | fb = FizzBuzz.new 7 | assert_equal "fizz", fb.convert(3) 8 | assert_equal "fizz", fb.convert(6) 9 | assert_equal "fizz", fb.convert(9) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/fizzbuzz.rb: -------------------------------------------------------------------------------- 1 | require_relative './fizz_detectable' 2 | require_relative './buzz_detectable' 3 | require_relative './fizzbuzz_detectable' 4 | 5 | class FizzBuzz 6 | include FizzDetectable 7 | include BuzzDetectable 8 | include FizzBuzzDetectable 9 | 10 | def convert(n) 11 | if fizzbuzz?(n) 12 | "fizzbuzz" 13 | elsif buzz?(n) 14 | "buzz" 15 | elsif fizz?(n) 16 | "fizz" 17 | else 18 | n 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/fizzbuzz_detectable.rb: -------------------------------------------------------------------------------- 1 | module FizzBuzzDetectable 2 | 3 | module_function 4 | 5 | def fizzbuzz?(n) 6 | n % 15 == 0 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/fizzbuzz_multicode/fizzbuzz_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative './fizzbuzz' 3 | 4 | class TestFizzBuzz < Minitest::Test 5 | def test_fizzbuzz 6 | fb = FizzBuzz.new 7 | assert_equal "fizzbuzz", fb.convert(15) 8 | assert_equal "fizzbuzz", fb.convert(30) 9 | assert_equal "fizzbuzz", fb.convert(45) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/helpers/git_helper.rb: -------------------------------------------------------------------------------- 1 | module TTNT 2 | module GitHelper 3 | module_function 4 | 5 | def git_commit(index, message) 6 | options = {} 7 | options[:tree] = index.write_tree(@repo) 8 | options[:author] = { email: "foo@bar.com", name: 'Author', time: Time.now } 9 | options[:committer] = options[:author] 10 | options[:message] = message 11 | options[:parents] = @repo.empty? ? [] : [@repo.head.target].compact 12 | options[:update_ref] = 'HEAD' 13 | Rugged::Commit.create(@repo, options) 14 | index.write 15 | end 16 | 17 | def git_commit_am(message) 18 | index = @repo.index 19 | index.read_tree(@repo.head.target.tree) unless @repo.empty? 20 | index.add_all 21 | git_commit(index, message) 22 | end 23 | 24 | def git_rm_and_commit(file, message) 25 | index = @repo.index 26 | index.read_tree(@repo.head.target.tree) unless @repo.empty? 27 | index.remove(file.gsub(/^#{@repo.workdir}\//, '')) 28 | git_commit(index, message) 29 | File.delete(file) if File.exist?(file) 30 | end 31 | 32 | def git_checkout_b(branch) 33 | @repo.create_branch(branch) 34 | @repo.checkout(branch) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/helpers/rake_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | module TTNT 4 | module RakeHelper 5 | module_function 6 | 7 | def load_rakefile(rakefiles) 8 | rakefiles = [rakefiles] unless rakefiles.is_a?(Array) 9 | Rake.application = Rake::Application.new 10 | Rake.application.init 11 | Rake.application.instance_variable_set(:@rakefiles, rakefiles) 12 | Rake.application.load_rakefile 13 | end 14 | 15 | def rake(task) 16 | result = capture { Rake::Task[task].execute } 17 | block_given? ? yield(result) : result 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/integration_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/test_to_code_mapping' 3 | 4 | module TTNT 5 | module IntegrationTest 6 | class FizzBuzz < TTNT::TestCase::FizzBuzz 7 | def test_saving_anchored_commit 8 | anchored_commit = @repo.head.target_id 9 | rake('ttnt:test:anchor') 10 | metadata = TTNT::MetaData.new(@repo) 11 | assert_equal anchored_commit, metadata['anchored_commit'] 12 | end 13 | 14 | def test_mapping_generation 15 | mapping = TTNT::TestToCodeMapping.new(@repo, @repo.head.target_id).mapping 16 | expected_mapping = {"buzz_test.rb"=>{"fizzbuzz.rb"=>[2, 3, 5, 6]}, 17 | "fizz_test.rb"=>{"fizzbuzz.rb"=>[2, 3, 5, 7, 8]}} 18 | assert_equal expected_mapping, mapping 19 | end 20 | 21 | def test_no_test_is_selected 22 | output = rake('ttnt:test:run') 23 | assert_equal "", output[:stdout] 24 | end 25 | 26 | def test_all_tests_are_selected_without_mapping 27 | git_rm_and_commit("#{@repo.workdir}/.ttnt", 'Remove .ttnt') 28 | rake_ttnt_result = rake('ttnt:test:run')[:stdout].lines.last 29 | rake_test_result = rake('test')[:stdout].lines.last 30 | assert_equal rake_test_result, rake_ttnt_result 31 | end 32 | 33 | def test_fizz_test_is_selected 34 | @repo.checkout('change_fizz') 35 | output = rake('ttnt:test:run') 36 | assert_match '1 runs, 1 assertions, 1 failures', output[:stdout] 37 | end 38 | 39 | def test_tests_are_selected_based_on_changes_in_current_working_tree 40 | @repo.checkout('change_fizz') 41 | # Change buzz too 42 | fizzbuzz_file = "#{@repo.workdir}/fizzbuzz.rb" 43 | File.write(fizzbuzz_file, File.read(fizzbuzz_file).gsub(/"buzz"$/, '"bar"')) 44 | output = rake('ttnt:test:run') 45 | assert_match '2 runs, 2 assertions, 2 failures', output[:stdout] 46 | end 47 | 48 | def test_isolated 49 | # Make TTNT select all tests 50 | git_rm_and_commit("#{@repo.workdir}/.ttnt", 'Remove .ttnt') 51 | ENV['ISOLATED'] = '1' 52 | output = rake('ttnt:test:run') 53 | assert_equal 3, output[:stdout].split('# Running:').count 54 | ensure 55 | ENV.delete('ISOLATED') 56 | end 57 | 58 | def test_isolated_with_fail_fast 59 | @repo.checkout('change_fizz') 60 | fizzbuzz_file = "#{@repo.workdir}/fizzbuzz.rb" 61 | File.write(fizzbuzz_file, File.read(fizzbuzz_file).gsub(/"buzz"$/, '"bar"')) 62 | ENV['ISOLATED'] = '1' 63 | ENV['FAIL_FAST'] = '1' 64 | output = rake('ttnt:test:run') 65 | assert_equal 2, output[:stdout].split('Failure:').count 66 | ensure 67 | ENV.delete('ISOLATED') 68 | ENV.delete('FAIL_FAST') 69 | end 70 | 71 | def test_select_untracked_files 72 | FileUtils.mkdir('test') 73 | fizz_test = './fizz_test.rb' 74 | File.write(fizz_test, File.read(fizz_test).gsub("require_relative '\.", "require_relative '..")) 75 | FileUtils.mv(fizz_test, './test/fizz_test.rb') 76 | output = rake('ttnt:test:run') 77 | assert_match '1 runs, 3 assertions, 0 failures', output[:stdout] 78 | end 79 | 80 | def test_storage_file_resides_with_rakefile 81 | Dir.mkdir('tmp') 82 | git_rm_and_commit("#{@repo.workdir}/.ttnt", 'Remove .ttnt file') 83 | %w(fizzbuzz.rb fizz_test.rb buzz_test.rb Rakefile).each do |file| 84 | FileUtils.mv file, 'tmp' 85 | git_rm_and_commit(file, "Remove #{file}") 86 | end 87 | git_commit_am("Move files into tmp") 88 | 89 | Dir.chdir('tmp') 90 | load_rakefile("#{Dir.pwd}/Rakefile") 91 | 92 | # Test writing to storage 93 | rake('ttnt:test:anchor') 94 | assert File.exist?("#{@repo.workdir}/tmp/.ttnt") 95 | assert !File.exist?("#{@repo.workdir}/.ttnt") 96 | git_commit_am('Add new .ttnt file under tmp directory') 97 | 98 | # Test reading from storage 99 | output = rake('ttnt:test:run') 100 | assert_match 'No test selected.', output[:stderr] 101 | end 102 | end 103 | 104 | class AdditionAmongComments < TTNT::TestCase::AdditionAmongComments 105 | def test_selecting_test_even_though_addition_is_made_among_comments 106 | double_file = "double.rb" 107 | File.write(double_file, File.read(double_file).gsub(/# ipsum$/, 'n *= 2')) 108 | output = rake('ttnt:test:run') 109 | assert_match '1 runs, 1 assertions, 1 failures', output[:stdout] 110 | end 111 | end 112 | 113 | class FizzBuzzMultiCode < TTNT::TestCase::FizzBuzzMultiCode 114 | def test_code_files_option 115 | fn = 'fizzbuzz.rb' 116 | File.write(fn, File.read(fn).gsub(/"fizzbuzz"$/, "foo")) 117 | output = rake('ttnt:test:run') 118 | assert_match 'No test selected.', output[:stderr], 119 | 'Changing files which are not specified in code_files should not select any tests.' 120 | 121 | fn = 'fizz_detectable.rb' 122 | File.write(fn, File.read(fn).gsub(/n % 3 == 0$/, "n % 3 == 1")) 123 | output = rake('ttnt:test:run') 124 | assert_match "Failure:\nTestFizz#test_fizz", output[:stdout] 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /test/metadata_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/metadata' 3 | 4 | class MetaDataTest < TTNT::TestCase::FizzBuzz 5 | def setup 6 | @storage_file = "#{@repo.workdir}/.ttnt" 7 | File.delete(@storage_file) 8 | 9 | @metadata = TTNT::MetaData.new(@repo) 10 | @name = 'anchored_sha' 11 | @value = 'abcdef' 12 | end 13 | 14 | def test_get_metadata 15 | File.write(@storage_file, { 'meta' => { @name => @value} }.to_json) 16 | assert @metadata[@name].nil?, '#get should not read from file.' 17 | @metadata.read! 18 | assert_equal @value, @metadata[@name] 19 | end 20 | 21 | def test_write_metadata 22 | @metadata[@name] = @value 23 | @metadata.write! 24 | expected = { 'meta' => { @name => @value } } 25 | assert_equal expected, JSON.parse(File.read(@storage_file)) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/storage_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/storage' 3 | 4 | class StorageTest < TTNT::TestCase::FizzBuzz 5 | def setup 6 | @storage_file = "#{@repo.workdir}/.ttnt" 7 | File.delete(@storage_file) 8 | 9 | @section = 'test' 10 | @storage = TTNT::Storage.new(@repo) 11 | @data = { 'a' => 1, 'b' => 2 } 12 | end 13 | 14 | def test_read_storage 15 | File.write(@storage_file, { @section => @data }.to_json) 16 | assert_equal @data, @storage.read(@section) 17 | end 18 | 19 | def test_read_storage_from_history 20 | @storage.write!(@section, @data) 21 | git_commit_am('Add data to storage file') 22 | sha = @repo.head.target_id 23 | new_data = { 'c' => 3 } 24 | @storage.write!(@section, new_data) # write to a file in working tree 25 | history_storage = TTNT::Storage.new(@repo, sha) 26 | assert !history_storage.read(@section).key?('c'), 27 | 'History storage should not contain data from current working directory.' 28 | end 29 | 30 | def test_read_absent_storage_from_history 31 | git_rm_and_commit("#{@repo.workdir}/.ttnt", 'Remove .ttnt file') 32 | storage = TTNT::Storage.new(@repo, @repo.head.target_id) 33 | assert_equal Hash.new, storage.read(@section) 34 | end 35 | 36 | def test_write_storage 37 | @storage.write!(@section, @data) 38 | assert File.exist?(@storage_file), 'Storage file should be created.' 39 | assert_equal @data, JSON.parse(File.read(@storage_file))[@section] 40 | end 41 | 42 | def test_cannot_write_to_history_storage 43 | sha = @repo.head.target_id 44 | history_storage = TTNT::Storage.new(@repo, sha) 45 | assert_raises { history_storage.write!(@section, @data) } 46 | end 47 | 48 | def test_storage_file_resides_with_rakefile 49 | @storage.write!(@section, @data) 50 | subdir = "#{@repo.workdir}/tmp" 51 | Dir.mkdir(subdir) 52 | rakefiles = ["#{@repo.workdir}/Rakefile", "#{subdir}/Rakefile"] 53 | FileUtils.copy rakefiles[0], rakefiles[1] 54 | 55 | TTNT.root_dir = nil 56 | load_rakefile(rakefiles) 57 | 58 | Dir.chdir(subdir) do 59 | storage = TTNT::Storage.new(@repo) 60 | assert_equal Hash.new, storage.read(@section) 61 | 62 | TTNT.root_dir = nil 63 | File.delete(rakefiles[1]) 64 | assert_equal @data, storage.read(@section) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | unless ENV['ANCHOR_TASK'] 2 | require 'coveralls' 3 | Coveralls.wear! 4 | end 5 | 6 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 7 | require 'ttnt' 8 | require 'helpers/git_helper' 9 | require 'helpers/rake_helper' 10 | 11 | require 'rugged' 12 | require 'minitest/autorun' 13 | 14 | module TTNT 15 | module TestCase 16 | class Base < Minitest::Test 17 | include GitHelper 18 | include RakeHelper 19 | 20 | def fixture_dir 21 | raise '`fixture_dir` method is not implemented.' 22 | end 23 | 24 | def before_setup 25 | super 26 | @save_pwd = Dir.pwd 27 | @tmpdir = Dir.mktmpdir('ttnt_repository') 28 | @repo = Rugged::Repository.init_at(@tmpdir) 29 | Dir.chdir(@repo.workdir) 30 | prepare_git_repository 31 | end 32 | 33 | def after_teardown 34 | Dir.chdir(@save_pwd) 35 | FileUtils.remove_entry_secure(@tmpdir) 36 | # remove cache 37 | TTNT.root_dir = nil 38 | super 39 | end 40 | 41 | private 42 | 43 | def prepare_git_repository 44 | populate_with_fixtures 45 | load_rakefile("#{@tmpdir}/Rakefile") 46 | anchor_and_commit 47 | after_preparing_git_repository 48 | end 49 | 50 | def populate_with_fixtures 51 | Dir.entries(fixture_dir).each do |file| 52 | next if file.start_with?('.') 53 | copy_fixture(file, "#{@tmpdir}/#{file}") 54 | git_commit_am("Add #{file}") 55 | end 56 | end 57 | 58 | def anchor_and_commit 59 | @anchored_sha = @repo.head.target_id 60 | rake('ttnt:test:anchor') 61 | git_commit_am('Add TTNT generated files') 62 | end 63 | 64 | def copy_fixture(src, dest) 65 | unless File.directory?(File.dirname(dest)) 66 | FileUtils.mkdir_p(File.dirname(dest)) 67 | end 68 | FileUtils.cp("#{fixture_dir}/#{src}", dest) 69 | end 70 | 71 | def capture 72 | captured_stream = Tempfile.new("stdout") 73 | origin_stream = $stdout.dup 74 | captured_stream_err = Tempfile.new("stderr") 75 | origin_stream_err = $stderr.dup 76 | $stdout.reopen(captured_stream) 77 | $stderr.reopen(captured_stream_err) 78 | 79 | yield 80 | 81 | $stdout.rewind 82 | $stderr.rewind 83 | return { stdout: captured_stream.read, stderr: captured_stream_err.read } 84 | ensure 85 | captured_stream.close 86 | captured_stream.unlink 87 | captured_stream_err.close 88 | captured_stream_err.unlink 89 | $stdout.reopen(origin_stream) 90 | $stderr.reopen(origin_stream_err) 91 | end 92 | end 93 | 94 | class FizzBuzz < Base 95 | def fixture_dir 96 | File.join(__dir__, 'fixtures/fizzbuzz') 97 | end 98 | 99 | private 100 | 101 | def after_preparing_git_repository 102 | make_change_fizz_branch 103 | end 104 | 105 | def make_change_fizz_branch 106 | git_checkout_b('change_fizz') 107 | fizzbuzz_file = "#{@repo.workdir}/fizzbuzz.rb" 108 | new_content = "\n" * 10 + File.read(fizzbuzz_file) # diff uglifier 109 | new_content.gsub!(/"fizz"$/, '"foo"') 110 | File.write(fizzbuzz_file, new_content) 111 | git_commit_am('Change fizz code') 112 | @repo.checkout('master') 113 | end 114 | end 115 | 116 | class AdditionAmongComments < Base 117 | def fixture_dir 118 | File.join(__dir__, 'fixtures/addition_among_comments') 119 | end 120 | 121 | private 122 | 123 | def after_preparing_git_repository 124 | # do nothing 125 | end 126 | end 127 | 128 | class FizzBuzzMultiCode < Base 129 | def fixture_dir 130 | File.join(__dir__, 'fixtures/fizzbuzz_multicode') 131 | end 132 | 133 | private 134 | 135 | def after_preparing_git_repository 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/test_selector_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/test_selector' 3 | 4 | module TTNT 5 | class TestSelectorTest < TTNT::TestCase::FizzBuzz 6 | def setup 7 | target_sha = @repo.branches['change_fizz'].target.oid 8 | @test_files = Rake::FileList['**/*_test.rb'] 9 | @selector = TTNT::TestSelector.new(@repo, target_sha, @test_files) 10 | end 11 | 12 | def test_base_obj_selection 13 | # Commit on which `rake ttnt:anchor` is invoked. Not the one `.ttnt` files are committed 14 | assert_equal @selector.instance_variable_get('@base_obj').oid, @anchored_sha 15 | end 16 | 17 | def test_selects_tests 18 | assert_equal nil, @selector.tests 19 | assert_equal ['fizz_test.rb'], @selector.select_tests!.to_a 20 | assert_equal ['fizz_test.rb'], @selector.tests.to_a 21 | end 22 | 23 | def test_selects_tests_from_current_working_tree 24 | @repo.checkout('change_fizz') 25 | 26 | # Change buzz too 27 | fizzbuzz_file = "#{@repo.workdir}/fizzbuzz.rb" 28 | selector = TTNT::TestSelector.new(@repo, nil, @test_files) 29 | 30 | File.write(fizzbuzz_file, File.read(fizzbuzz_file).gsub(/"buzz"$/, '"bar"')) 31 | 32 | assert_equal Set.new(['fizz_test.rb', 'buzz_test.rb']), selector.select_tests! 33 | end 34 | 35 | def test_selects_tests_with_changed_test_file 36 | buzz_test = "#{@repo.workdir}/buzz_test.rb" 37 | 38 | File.write(buzz_test, File.read(buzz_test) + "\n") # meaningless change 39 | 40 | git_checkout_b('change_buzz_test') # from master 41 | git_commit_am('Change buzz_test') 42 | 43 | target_sha = @repo.head.target_id 44 | selector = TTNT::TestSelector.new(@repo, target_sha, @test_files) 45 | 46 | assert_includes selector.select_tests!, 'buzz_test.rb' 47 | end 48 | 49 | def test_selects_all_tests_with_no_anchored_commit 50 | git_rm_and_commit("#{@repo.workdir}/.ttnt", 'Remove .ttnt file') 51 | selector = TTNT::TestSelector.new(@repo, @repo.head.target_id, @test_files) 52 | 53 | assert_equal Set.new(['fizz_test.rb', 'buzz_test.rb']), selector.select_tests! 54 | end 55 | 56 | def test_selects_untracked_test_files 57 | new_test = 'test/new_test.rb' 58 | selector = TTNT::TestSelector.new(@repo, nil, @test_files) 59 | 60 | FileUtils.mkdir('test') 61 | FileUtils.touch(new_test) 62 | 63 | assert_equal Set.new([new_test]), selector.select_tests! 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/test_to_code_mapping_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/test_to_code_mapping' 3 | 4 | class TestToCodeMappingTest < TTNT::TestCase::FizzBuzz 5 | def setup 6 | # clean up generated .ttnt files 7 | File.delete("#{@repo.workdir}/.ttnt") 8 | @test_to_code_mapping = TTNT::TestToCodeMapping.new(@repo) 9 | @test_file = 'fizz_test.rb' 10 | # Not a valid coverage, but an example 11 | @coverage = { "#{@repo.workdir}/fizzbuzz.rb"=> [1, 1, nil, 1, 0, 1, 0, 1, 0] } 12 | @test_to_code_mapping.append_from_coverage(@test_file, @coverage) 13 | end 14 | 15 | def test_append_from_coverage 16 | expected_mapping = { 17 | @test_file => { 'fizzbuzz.rb' => [1, 2, 4, 6, 8] } 18 | } 19 | assert_equal expected_mapping, @test_to_code_mapping.mapping 20 | end 21 | 22 | def test_get_tests 23 | assert_equal Set.new([@test_file]), 24 | @test_to_code_mapping.get_tests(file: 'fizzbuzz.rb', lineno: 3), 25 | 'It should select tests if the specified line is between the topmost executed line and downmost executed line in coverage' 26 | assert_equal Set.new([]), 27 | @test_to_code_mapping.get_tests(file: 'fizzbuzz.rb', lineno: 9), 28 | 'It should not select tests if the specified line is not between the topmost executed line and downmost executed line in coverage' 29 | assert_equal Set.new([@test_file]), 30 | @test_to_code_mapping.get_tests(file: 'fizzbuzz.rb', lineno: 2) 31 | end 32 | 33 | def test_select_code_files 34 | @test_to_code_mapping.select_code_files!([]) 35 | assert_equal Set.new([]), 36 | @test_to_code_mapping.get_tests(file: 'fizzbuzz.rb', lineno: 3) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/testtask_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'ttnt/testtask' 3 | require 'rake/testtask' 4 | 5 | class TestTaskTest < Minitest::Test 6 | def setup 7 | @name = 'sample_name' 8 | @rake_task, @ttnt_task = nil, nil 9 | # This will be in users' Rakefiles 10 | @rake_task = Rake::TestTask.new { |t| 11 | t.name = @name 12 | t.libs << 'test' 13 | t.pattern = 'test/**/*_test.rb' 14 | t.test_files = FileList['test/dummy_test.rb'] 15 | @ttnt_task = TTNT::TestTask.new(t) 16 | } 17 | end 18 | 19 | def test_define_rake_tasks 20 | assert Rake::Task.task_defined?("ttnt:#{@name}:anchor"), 21 | "`ttnt:#{@name}:anchor` task should be defined" 22 | assert Rake::Task.task_defined?("ttnt:#{@name}:run"), 23 | "`ttnt:#{@name}:run` task should be defined" 24 | end 25 | 26 | def test_composing_rake_testtask 27 | assert_equal @rake_task, @ttnt_task.rake_testtask 28 | end 29 | 30 | def test_expanded_file_list 31 | # It gathers tests from both `pattern` and `test_files` option for Rake::TestTask 32 | test_files = Rake::FileList['test/**/*_test.rb'] + ['test/dummy_test.rb'] 33 | assert_equal test_files, @ttnt_task.expanded_file_list 34 | end 35 | 36 | def test_instance_without_passing_rake_task 37 | default_rake_task = Rake::TestTask.new 38 | ttnt_task = TTNT::TestTask.new 39 | assert ttnt_task.instance_variable_get(:@rake_testtask).kind_of?(Rake::TestTask) 40 | end 41 | 42 | def test_yield_and_configure 43 | name = 'testname' 44 | test_files = 'foo_test' 45 | code_files = ['foo.rb', 'bar.rb'] 46 | ttnt_task = TTNT::TestTask.new { |t| 47 | t.name = name 48 | t.test_files = test_files 49 | t.code_files = code_files 50 | } 51 | assert Rake::Task.task_defined?("ttnt:#{name}:anchor"), 52 | "`ttnt:#{name}:anchor` task should be defined" 53 | assert_equal FileList[test_files], ttnt_task.test_files 54 | assert_equal code_files, ttnt_task.code_files 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/ttnt_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TTNTTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::TTNT::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /ttnt.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ttnt/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "ttnt" 8 | spec.version = TTNT::VERSION 9 | spec.authors = ["Genki Sugimoto"] 10 | spec.email = ["cfhoyuk.reccos.nelg@gmail.com"] 11 | 12 | spec.summary = %q{Select test cases to run based on changes in committed code} 13 | # spec.description = %q{TODO: Write a longer description or delete this line.} 14 | spec.homepage = "http://github.com/Genki-S/ttnt" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "rugged", "0.23.2" 23 | spec.add_dependency "json", "1.8.3" 24 | 25 | spec.add_development_dependency "bundler", "~> 1.10" 26 | spec.add_development_dependency "rake", "~> 10.0" 27 | spec.add_development_dependency "minitest" 28 | spec.add_development_dependency "yard" 29 | 30 | # Pry 31 | spec.add_development_dependency "hirb" 32 | spec.add_development_dependency "awesome_print" 33 | spec.add_development_dependency "pry" 34 | spec.add_development_dependency "pry-doc" 35 | spec.add_development_dependency "pry-byebug" 36 | spec.add_development_dependency "pry-rescue" 37 | end 38 | --------------------------------------------------------------------------------