charlesreid1.com blog

Git Workflows, Part 2: Crafting Commits

Posted in Git

permalink

Summary

  • Make your commits small and atomic, and recombine them into larger commits later; it's easier to combine smaller commits than to split large commits.

  • Make use of git add -p and git add -e to stage changes selectively and atomically.

  • Make use of git rebase and git cherry-pick to edit your commits and assemble them in the order you want.

  • Once commits have been combine and the history is satisfactory, push to a remote to share the work.

  • Think about ordering your commits to "tell a story". (What that means will depend on the people you are collaborating with!)

What is a commit

Before we get into the good stuff, let's talk about the anatomy of a git commit.

When you add files to your git repository, it's a two-step process: git add and git commit. The first step stages your changes, the second step memorializes those staged changes into a commit that can now be shared with others by pushing it to git remotes.

git add

Under the hood, git stores the full contents of each file at each snapshot in an object called a blob. When you modify a file and run git add, git writes a new blob containing the entire new contents of the file, and updates the staging area (the index) to point at that new blob. The diff between the old and new versions is not stored — it is computed on demand by comparing the two blobs.

That said, when using tools like git add -p, git diff, and git log -p, git presents changes at the line (hunk) level. This makes it useful to think about staging in terms of individual line changes, even though the underlying storage is snapshot-based. That line-level view is what makes it possible to stage some hunks in a file but not others — the resulting blob reflects only the hunks you accepted.

git commit

As you use git add to prepare your changes, the changes are added to a staging area. Think of this staging area as a draft commit. Each change being added to the staging area changes how the commit will look. When the changes are complete and the user runs git commit, it turns the staging area into a real commit, creates the metadata, and calculates hashes.

When a commit is created, it receives a name, which is the hash of the contents of the commit. The hash is computed from the contents of the blobs, plus the metadata about the commit, plus the hash of the prior commits. Changing a commit changes its hash, and will change the hashes of all subsequent commits.

Commits in your local repository can be easily rewritten and edited, and their hashes changed. A common workflow is to make many small commits, and recombine them later.

Because the commit hash is how the commit is named, modifying commits after you've shared them is bad practice and will create extra work for your collaborators. For that reason, don't git push until you're ready to share your work.

git rebase

The git rebase command allows you to edit your commit history. We will cover some usage patterns in the sections below.

git fetch and git pull

Before pushing changes to the remote, first check if there have been any commits since you began your branch.

rebase, merge, branch, pass

If a feature branch is created off of the master branch, and some time passes, the feature branch base commit may grow far out of sync with the master branch. (Note that master indicates the primary branch.)

This leaves the developer of the feature branch (which is out of sync with master) with a few choices:

  • rebase - continue to rebase all commits on the feature branch from the (old) original feature branch base commit onto the (new) head commit of the master branch.

    • Pros: clean history, easy for one-branch-one-developer workflow
    • Cons: requires continual force-pushes, requires coordination between developers to prevent squashing others' work, not scalable, some people hate this method
  • merge - occasionally merge work from the master branch into the feature branch.

    • Pros: simple to understand, simple to carry out, low cognitive load
    • Cons: any changes added to the branch via the merge commit will show up in the PR as new code, cluttering PR reviews by mixing features with merged changes; can also make the commit history messy and harder to understand.
  • branch - by making heavy use of throwaway branches and integration branches, it is easier to test out how the integration of a feature branch based on an old commit on master will do when merging it in with a newer version of master. Use throwaway integration branches to test out merging the two branches together, testing its functionality, etc. You can also rebase or cherry pick commits onto the throwaway integration branch, and figure out how to arrange the commits on a branch to "rebuild" it into a working, mergeable branch.

    • Pros: easy to do, encourages local use of throwaway branches
    • Cons: clutters branches, integration process has to be repeated (can be mitigated with git rerere), merge commits must wait until PR is approved
  • pass - best combined with the branch approach mentioned above, the pass approach is to leave the branch history clean, avoid force-pushes, and rely on throwaway branches to test out merge strategies once the inevitable PR merge needs to happen. It can also be useful to wait for code reviews to finish, then create a merge commit to make the merge happen smoothly.

    • Pros: easy to do
    • Cons: merge commits must wait until PR is approved

git push

Once you run git push, all of the commits on the branch that you pushed will end up on the remote, where others can access them. The purpose of a git push is to share commits, so generally you don't push branches until they are ready to share. This also allows more flexibility in crafting, rewriting, and combining commits.

force pushing

If you pushed a branch (which is a collection of commits) to a remote, and then you have edited those commits, you will run into a problem when you try and git push the new, edited versions of the commits to the same remote. The remote will detect that there are conflicting versions of the branch and will reject the changes.

That's where git push --force comes in. The --force flag tells the remote to discard its version of the branch and use the version of the branch that you are pushing.

We will cover more about force pushing - when to do it, when not to, and why some people hate it - in a later post. For now, we will only say that you should not force push often, since you can risk deleting others' work and creating additional confusion and work for all of your collaborators.

Commit Workflow

Principles

Here are some principles for your git commit workflow:

  • Commit small changes often.

  • Don't sweat the commit messages - they can be fixed up later.

  • Related - nobody will see your commits until you push your branch, so think of your branch as a scratch space. You have the ultimate freedom to use it however you want.

  • Branches are easy to create, so make liberal use of branches!

  • Be wary of force pushing, and of rewriting history.

Making Small Commits

Two essential git commands to help with making small commits are git add (patch mode) and git add (interactive mode).

git add patch mode

How to use:

git add -p <name-of-file>

The git add -p command allows the user to interactively stage individual changes made (in what is called patch mode). This means users can stage certain changes for one commit, then stage other changes for a different commit.

This solves the problem of making a long sequence of changes to a single file that should be logically separated into different steps. (For example, changing the import statements versus changing the name of a variable throughout a file).

For example, suppose we have the following changes to a file named doit.sh:

$ git diff doit.sh
diff --git a/doit.sh b/doit.sh
index 3b938a1..6c1aec8 100644
--- a/doit.sh
+++ b/doit.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 #
-# This script lists the 40 largest files in the git repo history
+# This script lists the 50 largest files in the git repo history

 $ git rev-list --all --objects | \
      sed -n $(git rev-list --objects --all | \
@@ -9,9 +9,9 @@ $ git rev-list --all --objects | \
      grep blob | \
      sort -n -k 3 | \
      \
-     tail -n40 | \
+     tail -n50 | \
      \
      while read hash type size; do
           echo -n "-e s/$hash/$size/p ";
      done) | \
-     sort -n -r -k1
+     sort -nru -k1

There are two related changes and one unrelated change, respectively: the two related changes are the change to the comment and the change to the tail command; the unrelated change is adding the -u flag to the sort command.

We can split these changes into two commits using git add -p doit.sh, which will walk through each change in the file and ask if we want to stage it:

$ git add -p doit.sh
diff --git a/doit.sh b/doit.sh
index 3b938a1..6c1aec8 100644
--- a/doit.sh
+++ b/doit.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 #
-# This script lists the 40 largest files in the git repo history
+# This script lists the 50 largest files in the git repo history

 $ git rev-list --all --objects | \
      sed -n $(git rev-list --objects --all | \
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y

@@ -9,9 +9,9 @@ $ git rev-list --all --objects | \
      grep blob | \
      sort -n -k 3 | \
      \
-     tail -n40 | \
+     tail -n50 | \
      \
      while read hash type size; do
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y

@@ -14,14 +14,14 @@        echo -n "-e s/$hash/$size/p ";
      done) | \
-     sort -n -r -k1
+     sort -nru -k1
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n

Now the two related changes are staged, and the unrelated change is not staged. This is reflected in git status:

$ git status
On branch master
Your branch is ahead of 'gh/master' by 2 commits.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   doit.sh

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   doit.sh

Now git commit will commit only the staged portions.

Run git commit with no filenames. If you pass a filename like git commit doit.sh, git will commit the current working-tree contents of that file directly, bypassing the index entirely — throwing away the careful hunk-by-hunk staging you just did and committing the unstaged changes too.

To use this in your workflow, think about how you can group different changes together into different commits. If you get a portion of a feature working, you can commit the changes in groups so that related changes get committed together.

Also remember that if your commit history ends up being excessively long or overly detailed, you can always examine what changes different commits made with git diff, and reorder them with git cherry-pick or modify/combine them with git rebase.

git add editor mode

How to use:

git add -e <name-of-file>

Like the interactive patch mode, git add -e allows you to selectively stage certain changes in a file. But it's much better for keyboard jockeys and those that love their text editor, because you can choose which changes to stage or not using the text editor.

A sidebar:

If you have not yet set the text editor that git uses, you should do that now. Modify your git configuration with this command:

git config --global core.editor vim

Alternatively, put the following in your ~/.gitconfig:

[core]
    editor = vim

(Or, you know, whatever your text editor of choice is.)

End of sidebar.

When you pass the -e flag to git add, it will open a new editor window with the full diff:

diff --git a/doit.sh b/doit.sh
index 326273c..14e4059 100644
--- a/doit.sh
+++ b/doit.sh
@@ -1,17 +1,17 @@
 #!/bin/bash
 #
-# This script lists the 50 largest files in the git repo history
+# This script lists the 10 largest files in the git repo history

 $ git rev-list --all --objects | \
      sed -n $(git rev-list --objects --all | \
      cut -f1 -d' ' | \
      git cat-file --batch-check | \
      grep blob | \
      sort -n -k 3 | \
      \
-     tail -n50 | \
+     tail -n10 | \
      \
      while read hash type size; do
           echo -n "-e s/$hash/$size/p ";
      done) | \
-     sort -nru -k1
+     sort -nr -k1

Editing this file requires some care!

Fortunately there is a section in the documentation for git add called Editing Patches.

Two things to remember:

  • Lines starting with + indicate new, added content. To prevent this content from being added, delete the line.

  • Lines starting with - indicate removed content. To keep this content, replace - with a space ().

Once you are finished, make sure you review the changes that are staged, particularly if this is the first time seeing patch files or the diff syntax.

Modifying Commits

There is always some reason or another to modify the commit history of a repository - perhaps someone's work was lost, or the wrong issue or pull request number was referenced, or a username was misspelled.

You can always modify a commit, but it will also modify every commit that came after it. Think of it like replaying the changes recorded in each commit onto the new branch. The contents of each commit changes slightly, so the hash (the name) of every commit changes.

git rebase

To do a git rebase, an interactive rebase (the -i flag) is recommended.

The general form of a rebase is:

git rebase [-i] <upstream> [<branch>]

If <branch> is given, git checks it out first. Then it takes every commit that is reachable from <branch> but not from <upstream> and replays them on top of <upstream>.

IMPORTANT: The <upstream> commit itself is not replayed - it is the new base. If you want to include a specific commit in the rebase, use its parent as <upstream>. For example, 0a1b2c3d~1 refers to the commit before 0a1b2c3d, so git rebase -i 0a1b2c3d~1 will replay 0a1b2c3d and everything after it on the current branch.

rebasing the last N commits of a branch

The most common form of interactive rebase is "let me edit the last N commits on my branch":

git rebase -i HEAD~N

For example, git rebase -i HEAD~5 replays the last 5 commits on top of their existing base, giving you an editor where you can reorder, squash, edit, or drop them.

rebasing onto another branch

To move a range of commits so they start on top of a different branch (for example, on top of the latest master), use the --onto flag:

git rebase --onto <newbase> <upstream> [<branch>]

<newbase> is where the commits will be replayed. <upstream> is the commit whose descendants get replayed (and <upstream> itself is not included). If <branch> is provided, git checks it out first before starting the rebase.

For example, suppose you have a feature branch that was branched off an old master, and you want to move its commits on top of the current master:

git rebase --onto master feature~3 feature

This replays the last 3 commits of feature on top of master, and because feature was supplied as <branch>, the feature label moves with the rebased commits — no headless state, no branch housekeeping needed.

Rearranging Commits

Where rebasing allows for editing commits en masse, cherry picking allows the changes made in individual commits to be applied anywhere - including other branches. This makes the atomic commit principle from the beginning of this post much easier - groups of related commits that happened out of order can be rearranged by cherry picking them onto a new branch, and the new branch is a better "story".

Combining Commits

The cherry pick operation can also be combined with a rebase - once multiple small commits are arranged together chronologically, a git rebase operation enables squashing those tiny commits into a small number of larger commits, all carrying related changes.

Tags:    git    rebase    cherry-pick    branching    version control   

Git Workflows, Part 1: Supercharging your Git Config

Posted in Git

permalink

Source

Most of the good stuff is from https://github.com/mathiasbynens/dotfiles!

User Section

Start off easy - here's how you set your email and name for commits:

[user]
    email = foo@bar.com
    name = Foo Bar

Bash Aliases

The Best One Letter Alias Ever

Start supercharging how you use git by creating a one-letter alias.

Add this to your ~/.bashrc file:

alias g="git"

You're already saving yourself a bunch of keystrokes, and we're just getting started!

Ending Bad Habits

This is a nice trick for getting yourself out of bad habits. My first time using a "sophisticated" branch worklow in git (i.e., not just committing and pushing to master all the time), I got in trouble for committing directly to master with a git push origin master (instead of making a feature branch and opening a pull request).

To get myself out of the habit of typing git push origin master, I wanted to map it to an alias that told me no. I did that by defining git to be a bash function (this works because functions take precedence over a binary named git on your path).

The git function checks the arguments that are passed to it. If the arguments are push origin master, it means I'm typing git push origin master, and I get a slap on the wrist.

Otherwise, it passes the arguments through to the git binary.

You can also put this in ~/.bashrc.

git() {
    if [[ $@ == "push origin master" ]]; then
        echo "nope"
    else
        command git "$@"
    fi
}

Alias section

In the ~/.gitconfig file, aliases specific to git can be defined in a section beginning with alias.

Log Utils

Let's start with some utilities for viewing git logs.

(You can never have too many ways to look at a git log.)

Note that we'll assume the alias bit in the following git config excerpts.

[alias]
    # courtesy of https://stackoverflow.com/a/34467298
    lg = !"git lg1"
    lg1 = !"git lg1-specific --all"
    lg2 = !"git lg2-specific --all"
    lg3 = !"git lg3-specific --all"

    lg1-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(auto)%d%C(reset)'
    lg2-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset)%C(auto)%d%C(reset)%n''          %C(white)%s%C(reset) %C(dim white)- %an%C(reset)'
    lg3-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset) %C(bold cyan)(committed: %cD)%C(reset) %C(auto)%d%C(reset)%n''          %C(white)%s%C(reset)%n''          %C(dim white)- %an <%ae> %C(reset) %C(dim white)(committer: %cn <%ce>)%C(reset)'

The git lgX shortcuts give similar views of the log, but with increasing vertical spacing. git lg1 is the most compact, while git lg3 is the most comfortable to read, as far as vertical whitespace. Same with the -specific commands.

This is one more nice short log command:

    # View abbreviated SHA, description, and history graph of the latest 20 commits
    l = log --pretty=oneline -n 20 --graph --abbrev-commit

Remember to use this with the g alias for super short log:

$ g l
* 4357b28 (HEAD -> source, gh/source, gh/HEAD) update mocking aws post
* 063ad78 add mocking post
* a5f1adc add init keras cnn post
* fb911ec add keras cnn draft
* 3549d35 add rosalind (euler paths) part 7 draft

$ g lg1
* 4357b28 - (67 minutes ago) update mocking aws post - Charles Reid (HEAD -> source, gh/source, gh/HEAD)
* 063ad78 - (2 weeks ago) add mocking post - Charles Reid
* a5f1adc - (4 months ago) add init keras cnn post - C Reid
* fb911ec - (5 months ago) add keras cnn draft - C Reid
* 3549d35 - (5 months ago) add rosalind (euler paths) part 7 draft - C Reid
...

Status Utils

The git status command is one of my most frequently used commands, so I made a few shortcuts:

    # View the current working tree status using the short format
    s = status -s
    ss = status

This makes checking the short or long status of a git repo easy:

$ g s
AM pelican/content/git-workflows-1-config.md
AM pelican/content/git-workflows-2-teams.md

$ g ss
On branch source
Your branch is ahead of 'gh/source' by 1 commit.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   pelican/content/git-workflows-1-config.md
    new file:   pelican/content/git-workflows-2-teams.md

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   pelican/content/git-workflows-1-config.md
    modified:   pelican/content/git-workflows-2-teams.md

Fetching

Fetching is handy to do, since it just fetches changes from a remote and doesn't actually change anything or try to merge anything (unlike a git pull command).

The most useful fetch command (git fetch --all) is aliased to g f with the following bit in the aliases section of the ~/.gitconfig file:

    f = fetch --all

Branch Utils

The only command I might use more than the status command are branch commands, so here are several branch aliases:

    b = branch -v
    bv = branch -v

    ba = branch -a
    bb = branch -v -a

In a similar way, you can get a summary view using g b:

$ g b
  master 4c828cd [behind 84] update with awsome day notes
* source b18adfd add two git workflow posts

$ g b
  master 940ee98 update mocking post
* source b18adfd add two git workflow posts

and a little bit more information with g bb:

$ g bb
  master            940ee98 update mocking post
* source            b18adfd add two git workflow posts
  remotes/gh/HEAD   -> gh/source
  remotes/gh/master 940ee98 update mocking post
  remotes/gh/source b18adfd add two git workflow posts

Branch and Checkout

Sometimes if you are creaing a branch with a long branch name, it can be inconvenient to have to first create the branch with git branch <branch-name> and then check it out with git checkout <branch-name>.

To resolve this you can define a git go alias that creates the branch and then switches to that branch:

    # Switch to a branch, creating it
    # from the current branch if necessary
    go = "!f() { git checkout -b \"$1\" 2> /dev/null || git checkout \"$1\"; }; f"

Careful you don't mistype the branch name. If you typo a new branch name that happens to collide with an existing branch, the checkout -b will fail (branch already exists) and the fallback checkout "$1" will silently switch you to that existing branch. Typo a branch name that doesn't exist at all, and both commands fail — you stay on your current branch and may not notice. Always sanity check with g b after running g go.

Remote Utils

Another useful git command is the remote command, so here are a few remote aliases:

    r = remote -v
    rv = remote -v
    ra = remote -v

Commit Utils

Sometimes you have changes that you've staged using git add, but you want to see the changes that you've staged, before you commit them.

Normally you'd have to use the inconvenient git diff --cached <files>, but this can be aliased to cdiff, so that you can use git diff to see unstaged changes and git cdiff to see staged changes.

Even better, you can define the alias g cd to run git cdiff...!

Here's the relevant bit in the aliases section:

    cdiff = diff --cached
    cd = diff --cached

Committing All Changes

    # Commit all changes
    ca = !git add -A && git commit -av

Fixing Commits

Some common operations for repairing commit history before pushing:

    # Amend the currently staged files to the latest commit
    amend = commit --amend --reuse-message=HEAD

    # Oops
    fix = commit --amend --reuse-message=HEAD --edit

Miscellaneous Utils

There are a few other actions that are useful to add to the aliases section of the ~/.gitconfig:

Rebasing shortcuts

    # Interactive rebase with the given number of latest commits
    reb = "!r() { git rebase -i HEAD~$1; }; r"

Diff shortcuts

    # Show the diff between the latest commit and the current state
    d = !"git diff-index --quiet HEAD -- || clear; git --no-pager diff --patch-with-stat"

    # `git di $number` shows the diff between the state `$number` revisions ago and the current state
    di = !"d() { git diff --patch-with-stat HEAD~$1; }; git diff-index --quiet HEAD -- || clear; d"

Pull shortcuts

    p = "!f() { git pull $1 $2; }; f"

Clone shortcuts

    # Clone a repository including all submodules
    c = clone --recursive

Contributor shortcuts

This last one is convenient for getting a summary of contributors:

    # List contributors with number of commits
    contributors = shortlog --summary --numbered

An example for https://github.com/aws/chalice:

$ cd chalice/
$ g contributors
  1053  James Saryerwinnie
   120  John Carlyle
    94  stealthycoin
    42  kyleknap
    35  jcarlyl
    19  Kyle Knapp
    12  Atharva Chauthaiwale

Core section

Because it's the best text editor:

[core]
    editor = vim

I have some other stuff I've collected, many of them from https://github.com/mathiasbynens/dotfiles:

    # Use custom `.gitignore` and `.gitattributes`
    excludesfile = ~/.gitignore
    attributesfile = ~/.gitattributes

    # Treat spaces before tabs and all kinds of trailing whitespace as an error
    # [default] trailing-space: looks for spaces at the end of a line
    # [default] space-before-tab: looks for spaces before tabs at the beginning of a line
    whitespace = space-before-tab,-indent-with-non-tab,trailing-space

    # Make `git rebase` safer on macOS
    # More info: <http://www.git-tower.com/blog/make-git-rebase-safe-on-osx/>
    ###trustctime = false

    # Prevent showing files whose names contain non-ASCII symbols as unversioned.
    # http://michael-kuehnel.de/git/2014/11/21/git-mac-osx-and-german-umlaute.html
    precomposeunicode = false

    # Speed up commands involving untracked files such as `git status`.
    # https://git-scm.com/docs/git-update-index#_untracked_cache
    untrackedCache = true

Color section

Make some nice beautiful colors that are easy to understand:

[color]

    # Use colors in Git commands that are capable of colored output when
    # outputting to the terminal. (This is the default setting in Git ≥ 1.8.4.)
    ui = auto

[color "branch"]

    current = yellow reverse
    local = yellow
    remote = green

[color "diff"]

    meta = yellow bold
    frag = magenta bold # line info
    old = red # deletions
    new = green # additions

[color "status"]

    added = yellow
    changed = green
    untracked = cyan

Url section

This makes some Github-related URLs easier and shorter to type:

[url "git@github.com:"]

    insteadOf = "gh:"
    pushInsteadOf = "github:"
    pushInsteadOf = "git://github.com/"

[url "git@gist.github.com:"]

    insteadOf = "gst:"
    pushInsteadOf = "gist:"
    pushInsteadOf = "git://gist.github.com/"

[url "git://gist.github.com/"]

    insteadOf = "gist:"

Now, instead of

$ git clone git@github.com:org-name/repo-name

you can do the much simpler

$ g c gh://org-name/repo-name

Voila! Start integrating these alises into your daily workflow, and you'll find yourself using a lot fewer keystrokes!

Tags:    git    rebase    cherry-pick    branching    version control   

Mocking AWS in Unit Tests

Posted in Python

permalink

Overview

This post covers a technique for mocking AWS in unit tests so that you can test functionality that normally requires API calls and handling responses, by mocking those responses instead of making actual API calls.

A Simple Example: Mocking API Responses

The Genuine AWS Call

Let's start with an example of an AWS API call. Here's how our program will be structured: start with a driver lister.py that creates an AWS secrets manager client and defines a function to list secrets using the secrets manager client, then a test for it in test_lister.py that mocks the AWS call.

This example is simple and uses just one function, list_secrets(), which returns a JSON response that looks something like this:

{
  "SecretList": [
    {
      "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:prefix/secret1-abc123",
      "Name": "prefix/es_source_ip",
      "LastChangedDate": "2019-09-23 17:29:16.267000-07:00",
      "LastAccessedDate": "2019-09-23 17:00:00-07:00",
      "SecretVersionsToStages": {
        "658c3b41-0806-48b9-b05d-ea7dc2dbf237": [
          "AWSCURRENT"
        ],
        "f37ccfe2-16e0-4305-a250-ef89d2c47ece": [
          "AWSPREVIOUS"
        ]
      }
    },
    {
      "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:prefix/secret2-def789",
      "Name": "prefix/secret2",
      "LastChangedDate": "2019-09-22 17:05:01.431000-07:00",
      "LastAccessedDate": "2019-09-22 17:00:00-07:00",
      "SecretVersionsToStages": {
        "95AE5F8B-34E7-4EDF-A672-9E3AF1A4732E": [
          "AWSCURRENT"
        ],
        "F29E224A-BC03-4780-B64E-EA666B99D952": [
          "AWSPREVIOUS"
        ]
      }
    }
  ]
}

Using the secrets manager API:

lister.py:

import boto3

sm_client = boto3.client('secretsmanager')

def print_secret_names():
    s = sm_client.list_secrets()
    for secret in s['SecretList']:
        if 'Name' in secret and 'LastAccessedDate' in secret:
            print(f"Secret Name: {secret['Name']} (last accessed: {secret['LastAccessedDate']})")

if __name__=="__main__":
    print_secret_names()

If we run this file, we'll see a list of secrets in the real secrets manager - that is, the secrets manager that is linked to the boto credentials in ~/.aws, so the secrets we see are the actual secrets in the secret manager:

$ python lister.py
Secret Name: prefix/secret1 (last accessed: 2019-09-23 17:00:00-07:00)
Secret Name: prefix/secret2 (last accessed: 2019-09-23 17:00:00-07:00)
Secret Name: prefix/secret3 (last accessed: 2019-09-23 17:00:00-07:00)

The Mocked AWS Call

It is important to only mock the functionality we need. We should mock the returned JSON, but only the Name and LastAccessedDate fields.

To mock the call to list_secrets(), we start by importing mock from unittest. Then we import the file that has the function we want to test. We also import any other modules we need.

Next, we are mocking a call to a method of an object, which we can do by creating a context via with mock.patch() (and passing it a string with the name of the object we want to mock, or patch).

import unittest
from unittest import mock
import lister
import datetime

class TestLister(unittest.TestCase):
    def test_main(self):
        with mock.patch("lister.sm_client") as sm:
            ...
            sm.list_secrets = mock.MagicMock( ... )
            ...

Any calls made to sm_client in the lister module will be mocked using the mock.MagicMock object that we define in the context, so we craft the response we want before we call the method we want to test (which in turn will call sm_client.list_secrets()).

The full version of the test looks like this:

test_lister.py:

import unittest
from unittest import mock
import lister
import datetime

class TestLister(unittest.TestCase):
    def test_main(self):
        with mock.patch("lister.sm_client") as sm:
            return_json = {
                "SecretList": [
                    {
                        "Name": "fakesecret1",
                        "LastAccessedDate": datetime.datetime.now()
                    },
                    {
                        "Name": "fakesecret2",
                        "LastAccessedDate": datetime.datetime.now()
                    }
                ]
            }
            sm.list_secrets = mock.MagicMock(return_value = return_json)
            lister.print_secret_names()

if __name__=="__main__":
    unittest.main()

When the test file is run via Python, we see the fake secrets:

$ python test_lister.py
Secret Name: fakesecret1 (last accessed: 2019-09-23 20:31:49.186874)
Secret Name: fakesecret2 (last accessed: 2019-09-23 20:31:49.186880)
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Where To Patch: A Common Gotcha

The single most confusing thing about mock.patch is that you patch a name where it is looked up, not where it is defined. This trips up almost everyone the first time they reach for unittest.mock.

In our example above, lister.py does import boto3 at the top and then binds a client to a module-level name:

sm_client = boto3.client('secretsmanager')

That means the name sm_client now lives in the lister module's namespace. The patch target "lister.sm_client" works because that is the exact namespace where our code looks the name up when it runs.

A very natural (but broken) instinct is to try to patch boto3 itself:

# This looks reasonable, but does nothing useful.
with mock.patch("boto3.client") as bc:
    bc.return_value.list_secrets = mock.MagicMock(return_value=return_json)
    lister.print_secret_names()

This patch replaces boto3.client in the boto3 module, but lister.py already called boto3.client('secretsmanager') at import time and stored the result in lister.sm_client. That reference is unaffected by the patch, so the real AWS client is still used and the test either makes a real API call or fails with a credentials error.

The rule of thumb: find the line in your code that actually uses the name, then patch it in that module's namespace. If lister.py did the import inside the function instead:

def print_secret_names():
    import boto3
    sm_client = boto3.client('secretsmanager')
    ...

then "lister.boto3" would be the correct patch target, because that is where boto3 is looked up at call time.

Once test files grow past a handful of nested with mock.patch(...) blocks, the mocker fixture from pytest-mock is a cleaner way to express the same patches without the indentation pyramid.

When To Reach For moto Instead

unittest.mock is a good fit when you need to stub out a single call or two, but it has real limits:

  • You hand-craft the response payload, so nothing checks that the shape you invented actually matches the real AWS API.
  • Multi-step flows (create a secret, then list it, then rotate it) require hand-wiring the state changes between mock calls.
  • Every new AWS method you touch is another mock.MagicMock(...) to author and maintain.

For anything beyond a small handful of calls, moto is usually the better tool. moto runs an in-process fake of the AWS services themselves, so your code calls boto3 normally and gets back responses whose shapes match the real API. You can create resources, list them, mutate them, and delete them the same way you would in production.

Here is the same test written against moto:

test_lister_moto.py:

import boto3
import lister
from moto import mock_secretsmanager

@mock_secretsmanager
def test_print_secret_names(capsys):
    client = boto3.client("secretsmanager", region_name="us-east-1")
    client.create_secret(Name="fakesecret1", SecretString="hunter2")
    client.create_secret(Name="fakesecret2", SecretString="correct horse")

    # Reset lister's cached client so it picks up the moto-patched boto3.
    lister.sm_client = boto3.client("secretsmanager", region_name="us-east-1")

    lister.print_secret_names()

    captured = capsys.readouterr()
    assert "fakesecret1" in captured.out
    assert "fakesecret2" in captured.out

A few things to notice:

  • The @mock_secretsmanager decorator patches botocore at the HTTP layer, so the "where to patch" gotcha from the previous section does not apply - any boto3 client constructed inside the decorated scope talks to the fake.
  • We exercise the real create_secret and list_secrets code paths. If AWS changes a field name, or our code assumes a field that the API does not actually return, the test will notice.
  • We assert on the output of print_secret_names() via capsys, not just that it ran without exceptions.

moto also supports a standalone server mode that speaks the AWS wire protocol on a local port, which is useful for integration tests that span multiple services or non-Python clients.

The rough guideline: reach for unittest.mock when you are stubbing a single call to isolate one unit of code, and reach for moto when the test needs the service to behave like AWS across several calls.

Tags:    python    pytest    tests    aws    mock    mocking   

March 2022

How to Read Ulysses

July 2020

Applied Gitflow

September 2019

Mocking AWS in Unit Tests

May 2018

Current Projects

November 2017

A Hard(y) Math Problem