Programatically to programmatically.
6.1 KiB
Crafting Git Tests
This document outlines the options available for creating tests around Git repositories and data.
Repository Fixtures
The app/test/fixtures
directory is a special folder on disk that can be used
to capture snapshots of a Git repository for testing. This is ideal for
situations where:
- capturing the whole repository state (including working directory and config), such as a regression test
- the manual work to create the repository from scratch is not worth performing on every test run
There are some downsides to this approach:
- the entire repository is committed within the Desktop repository, so large repositories are not suitable to be imported with this approach
- there is automated tooling for importing a repository currently
- updating a repository if a test case changes is not supported - you'll need to do the import again with the new state
Importing a Git repository
The manual steps to add a repository fixture for testing are:
- inside the Git repository you want to import, rename all
.git
directories to_git
- create a new folder inside
app/test/fixtures
named in a way that relates to the tests being performed on it. The placeholder{your-new-folder}
in subsequent steps to represent whatever name you have chosen here. - copy the repository contents into
app/test/fixtures/{your-new-folder}
- cleanup any
app/test/fixtures/{your-new-folder}/_git/hooks/*.sample
files as these are not needed for testing (and will simplify the diff a bit)
Once you have done that, in your test you need to use setupFixtureRepository
with the first parameter being the name of your test fixture folder.
This will return a temporary path to the repository, which is cleaned up after the test run is completed.
Here's an example of a test using setupFixtureRepository
:
import {
setupFixtureRepository,
} from '../../helpers/repositories'
// ...
it('returns detached for arbitrary checkout', async () => {
const path = await setupFixtureRepository('{your-new-folder}')
const repository = new Repository(path, -1, null, false)
// act
// assert
})
Repository Scaffolding
The newer approach that we are experimenting with is to provide scaffolding APIs to declaratively get the repository into a state for testing. This approach is ideal for situations when:
- a baseline repository can differ slightly between tests, and it's easier to programmatically apply the changes than create multiple test fixtures
- the workflows being developed may change over time, and the tests themselves should be flexible (and be easy to identify how they evolve)
Three patterns have been implemented to support workflows we are currently developing:
cloneRepository
- make a copy of a test repository so thatpush
/pull
/fetch
can be emulated and tested without using the networkmakeCommit
- express the changes to the working directory that should be committedswitchTo
- a quick way to checkout (and create if needed) a branch in the repository
This is an example test for pull
behaviour which uses this scaffolding:
describe('ahead and behind of tracking branch', () => {
let repository: Repository
beforeEach(async () => {
const remoteRepository = await createRepository(featureBranch)
repository = await cloneRepository(remoteRepository)
// make a commits to both remote and local so histories diverge
const changesForRemoteRepository = {
commitMessage: 'Changed a file in the remote repository',
entries: [
{
path: 'README.md',
contents: '# HELLO WORLD! \n WORDS GO HERE! \nLOL',
},
],
}
await makeCommit(remoteRepository, changesForRemoteRepository)
const changesForLocalRepository = {
commitMessage: 'Added a new file to the local repository',
entries: [
{
path: 'CONTRIBUTING.md',
contents: '# HELLO WORLD! \nTHINGS GO HERE\nYES, THINGS',
},
],
}
await makeCommit(repository, changesForLocalRepository)
await fetch(repository, null, origin)
})
describe('by default', () => {
let previousTip: Commit
let newTip: Commit
beforeEach(async () => {
previousTip = await getTipOrError(repository)
await pull(repository, null, origin)
newTip = await getTipOrError(repository)
})
it('creates a merge commit', async () => {
const newTip = await getTipOrError(repository)
expect(newTip.sha).not.toBe(previousTip.sha)
expect(newTip.parentSHAs).toHaveLength(2)
})
})
})
Additional Git operations
Once you have a Repository
initialized in a test, if you need to run
additional Git commands on the repository it is recommended to use
GitProcess.exec
from dugite
. We recommend this approach over reusing the Git
APIs created for Desktop in app/src/lib/git
for a few reasons:
- the Git command line has lots of detailed documentation and options, which we can use without needing to add this behaviour to Desktop's Git APIs
- test setup should not be coupled to application behaviour, in case that application behaviour can change in the future
- by making the Git test setup explicit, we can focus on what Git operation is being tested
This is an example of how we use GitProcess.exec
in our tests:
it('returns remotes sorted alphabetically', async () => {
const repository = await setupEmptyRepository()
// adding these remotes out-of-order to test how they are then retrieved
const url = 'https://github.com/desktop/not-found.git'
await GitProcess.exec(['remote', 'add', 'X', url], repository.path)
await GitProcess.exec(['remote', 'add', 'A', url], repository.path)
await GitProcess.exec(['remote', 'add', 'L', url], repository.path)
await GitProcess.exec(['remote', 'add', 'T', url], repository.path)
await GitProcess.exec(['remote', 'add', 'D', url], repository.path)
const result = await getRemotes(repository)
expect(result).toHaveLength(5)
expect(result[0].name).toEqual('A')
expect(result[1].name).toEqual('D')
expect(result[2].name).toEqual('L')
expect(result[3].name).toEqual('T')
expect(result[4].name).toEqual('X')
})