From f7a0ee01e269bcf5e291016de01fe6406ec54c22 Mon Sep 17 00:00:00 2001 From: Curtis Vogt Date: Mon, 15 Jul 2024 22:08:40 +0200 Subject: [PATCH] Package documentation (#123) * Create initial documentation framework * Use separate documentation test/deploy workflows * GHA fixes * Fix doctests * Change docs deploy job name * Documentation overhaul * Refactor randdev example * Recommendation on where to place `Mocking.activate` * Update doctests in workflow * Update Documenter workflow --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/CI.yml | 37 ++++++++++ .github/workflows/Documenter.yml | 44 ++++++++++++ .gitignore | 4 +- README.md | 106 +--------------------------- docs/Project.toml | 3 + docs/make.jl | 30 ++++++++ docs/src/api.md | 19 +++++ docs/src/faq.md | 30 ++++++++ docs/src/index.md | 86 +++++++++++++++++++++++ src/dispatch.jl | 12 +++- src/mock.jl | 24 +++++++ src/patch.jl | 115 +++++++++++++++++++++++++------ test/{readme.jl => randdev.jl} | 4 +- test/runtests.jl | 5 +- 14 files changed, 387 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/Documenter.yml create mode 100644 docs/Project.toml create mode 100644 docs/make.jl create mode 100644 docs/src/api.md create mode 100644 docs/src/faq.md create mode 100644 docs/src/index.md rename test/{readme.jl => randdev.jl} (92%) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f6bd1e1..c235951 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -38,3 +38,40 @@ jobs: - uses: codecov/codecov-action@v4 with: files: lcov.info + + doc-tests: + name: Doctests + runs-on: ubuntu-latest + + # These permissions are needed to: + # - Checkout the repo + # - Delete old caches: https://github.com/julia-actions/cache#usage + # - Deploy the docs to the `gh-pages` branch: https://documenter.juliadocs.org/stable/man/hosting/#Permissions + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: "1" + - uses: julia-actions/cache@v2 + - name: Configure docs environment + shell: julia --project=docs --color=yes {0} + run: | + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate() + - name: Run Doctests + shell: julia --project=docs --color=yes {0} + run: | + using Documenter: DocMeta, doctest + using Mocking: Mocking + + setup = quote + using Mocking: @mock, @patch, activate, apply + activate() + end + + DocMeta.setdocmeta!(Mocking, :DocTestSetup, setup; recursive=true) + doctest(Mocking; manual=false) diff --git a/.github/workflows/Documenter.yml b/.github/workflows/Documenter.yml new file mode 100644 index 0000000..bbcb0cf --- /dev/null +++ b/.github/workflows/Documenter.yml @@ -0,0 +1,44 @@ +name: Documenter +on: + push: + branches: ["main"] + tags: ["*"] + paths: + - "docs/**" + - "src/**" + - "Project.toml" + - ".github/workflows/Documenter.yml" + pull_request: + paths: + - "docs/**" + - "src/**" + - "Project.toml" + - ".github/workflows/Documenter.yml" + +jobs: + deploy: + runs-on: ubuntu-latest + + # These permissions are needed to: + # - Checkout the repo + # - Delete old caches: https://github.com/julia-actions/cache#usage + # - Deploy the docs to the `gh-pages` branch: https://documenter.juliadocs.org/stable/man/hosting/#Permissions + permissions: + actions: write + contents: write + statuses: write + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: "1" + - uses: julia-actions/cache@v2 + - name: Configure docs environment + shell: julia --project=docs --color=yes {0} + run: | + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate() + - uses: julia-actions/julia-docdeploy@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 97e6a6f..864efbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +/Manifest.toml +/docs/Manifest.toml +/docs/build *.jl.cov *.jl.*.cov *.jl.mem -/Manifest.toml diff --git a/README.md b/README.md index dfaf20f..1ee6e60 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,11 @@ Mocking ======= +[![Stable Documentation](https://img.shields.io/badge/docs-stable-blue.svg)](https://juliatesting.github.io/Mocking.jl/stable) +[![Dev Documentation](https://img.shields.io/badge/docs-dev-blue.svg)](https://juliatesting.github.io/Mocking.jl/dev) [![CI](https://github.com/JuliaTesting/Mocking.jl/workflows/CI/badge.svg)](https://github.com/JuliaTesting/Mocking.jl/actions?query=workflow%3ACI+branch%3Amain) [![codecov](https://codecov.io/gh/JuliaTesting/Mocking.jl/graph/badge.svg?token=BkilUame8F)](https://codecov.io/gh/JuliaTesting/Mocking.jl) [![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/invenia/BlueStyle) [![ColPrac: Contributor Guide on Collaborative Practices for Community Packages](https://img.shields.io/badge/ColPrac-Contributor's%20Guide-blueviolet)](https://github.com/SciML/ColPrac) - -Allows Julia function calls to be temporarily overloaded for purpose of testing. - -Contents --------- - -- [Usage](#usage) -- [Gotchas](#gotchas) -- [Overhead](#overhead) - -Usage ------ - -Suppose you wrote the function `randdev` (UNIX only). How would you go about writing tests -for it? - -```julia -function randdev(n::Integer) - open("/dev/urandom") do fp - reverse(read(fp, n)) - end -end -``` - -The non-deterministic behaviour of this function makes it hard to test but we can write some -tests dealing with the deterministic properties of the function: - -```julia -using Test -using ...: randdev - -n = 10 -result = randdev(n) -@test eltype(result) == UInt8 -@test length(result) == n -``` - -How could we create a test that shows the output of the function is reversed? Mocking.jl -provides the `@mock` macro which allows package developers to temporarily overload a -specific calls in their package. In this example we will apply `@mock` to the `open` call -in `randdev`: - -```julia -using Mocking - -function randdev(n::Integer) - @mock open("/dev/urandom") do fp - reverse(read(fp, n)) - end -end -``` - -With the call site being marked as "mockable" we can now write a testcase which allows -us to demonstrate the reversing behaviour within the `randdev` function: - -```julia -using Mocking -using Test -using ...: randdev - -Mocking.activate() # Need to call `activate` before executing `apply` - -n = 10 -result = randdev(n) -@test eltype(result) == UInt8 -@test length(result) == n - -# Produces a string with sequential UInt8 values from 1:n -data = unsafe_string(pointer(convert(Array{UInt8}, 1:n))) - -# Generate an alternative method of `open` which call we wish to mock -patch = @patch open(fn::Function, f::AbstractString) = fn(IOBuffer(data)) - -# Apply the patch which will modify the behaviour for our test -apply(patch) do - @test randdev(n) == convert(Array{UInt8}, n:-1:1) -end - -# Outside of the scope of the patched environment `@mock` is essentially a no-op -@test randdev(n) != convert(Array{UInt8}, n:-1:1) -``` - -Gotchas -------- - -Remember to: - -- Use `@mock` at desired call sites -- Run `Mocking.activate()` before executing any `apply` calls - -Overhead --------- - -The `@mock` macro uses a conditional check of `Mocking.activated()` which only allows -patches to be utilized only when Mocking has been activated. By default, Mocking starts as -disabled which should result conditional being optimized away allowing for zero-overhead. -Once activated via `Mocking.activate()` the `Mocking.activated` function will be -re-defined, causing all methods dependent on `@mock` to be recompiled. - -License -------- - -Mocking.jl is provided under the [MIT "Expat" License](LICENSE.md). +Allows Julia function calls to be temporarily overloaded for the purpose of testing. diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..378ed84 --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,3 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +Mocking = "78c3b35d-d492-501b-9361-3d52fe80e533" diff --git a/docs/make.jl b/docs/make.jl new file mode 100644 index 0000000..39c1d17 --- /dev/null +++ b/docs/make.jl @@ -0,0 +1,30 @@ +using Documenter +using Mocking: Mocking + +setup = quote + using Mocking: @mock, @patch, activate, apply + activate() +end + +DocMeta.setdocmeta!(Mocking, :DocTestSetup, setup; recursive=true) + +makedocs(; + modules=[Mocking], + authors="Curtis Vogt and contributors", + sitename="Mocking.jl", + format=Documenter.HTML(; + canonical="https://juliatesting.github.io/Mocking.jl", + edit_link="main", + assets=String[], + prettyurls=get(ENV, "CI", nothing) == "true", # Fix links in local builds + ), + pages=[ + "Home" => "index.md", + "FAQ" => "faq.md", + "API" => "api.md", + # format trick: using this comment to force use of multiple lines + ], + warnonly=[:missing_docs], +) + +deploydocs(; repo="github.com/JuliaTesting/Mocking.jl", devbranch="main") diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..ee88e9b --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,19 @@ +# API + +```@meta +CurrentModule = Mocking +``` + +```@index +``` + +--- + +```@docs +Mocking.activate +Mocking.activated +Mocking.nullify +Mocking.@mock +Mocking.@patch +Mocking.apply +``` diff --git a/docs/src/faq.md b/docs/src/faq.md new file mode 100644 index 0000000..1129023 --- /dev/null +++ b/docs/src/faq.md @@ -0,0 +1,30 @@ +# FAQ + +```@meta +CurrentModule = Mocking +``` + +## What kind of overhead does `@mock` add? + +The [`@mock`](@ref) macro is a no-op and has zero overhead when mocking has not been activated via +[`Mocking.activate()`](@ref activate). Users can use `@code_llvm` on their code with and without `@mock` to +confirm the macro has no effect. + +When `Mocking.activate` is called Mocking.jl will re-define a function utilized by `@mock` +which results in invalidating any functions using the macro. The result of this is that when +running your tests will cause those functions to be recompiled the next time they are called +such that the alternative code path provided by patches can be executed. + +## Why isn't my patch being called? + +When your patch isn't being applied you should remember to check for the following: + +- [`Mocking.activate`](@ref activate) is called before the [`apply`](@ref) call. +- Call sites you want to patch are using [`@mock`](@ref). +- The patch's argument types are supertypes the values passed in at the call site. + +## Where should I add `Mocking.activate()`? + +We recommend putting the call to [`Mocking.activate`](@ref activate) in your package's +`test/runtests.jl` file after all of your import statements. The only true requirement is +that you call `Mocking.activate()` before the first [`apply`](@ref) call. diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..4ab6f8f --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,86 @@ +# Mocking + +Allows Julia function calls to be temporarily overloaded for the purpose of testing. + +## `randdev` Example + +Suppose you wrote the function `randdev` (UNIX only). How would you go about writing tests +for it? + +```jldoctest randdev; output=false +function randdev(n::Integer) + open("/dev/urandom") do fp + reverse(read(fp, n)) + end +end + +# output +randdev (generic function with 1 method) +``` + +The non-deterministic behaviour of this function makes it hard to test but we can write some +tests dealing with the deterministic properties of the function such as: + +```jldoctest randdev; output=false +using Test +# using ...: randdev + +n = 10 +result = randdev(n) +@test eltype(result) == UInt8 +@test length(result) == n + +# output +Test Passed +``` + +How could we create a test that shows the output of the function is reversed? Mocking.jl +provides the `@mock` macro which allows package developers to temporarily overload a +specific calls in their package. In this example we will apply `@mock` to the `open` call +in `randdev`: + +```jldoctest randdev_mock; output=false +using Mocking: @mock + +function randdev(n::Integer) + @mock open("/dev/urandom") do fp + reverse(read(fp, n)) + end +end + +# output +randdev (generic function with 1 method) +``` + +With the call site being marked as "mockable" we can now write a testcase which allows +us to demonstrate the reversing behaviour within the `randdev` function: + +```jldoctest randdev_mock; output=false +using Mocking +using Test +# using ...: randdev + +Mocking.activate() # Need to call `activate` before executing `apply` + +n = 10 +result = randdev(n) +@test eltype(result) == UInt8 +@test length(result) == n + +# Produces a string with sequential UInt8 values from 1:n +data = unsafe_string(pointer(convert(Array{UInt8}, 1:n))) + +# Generate an alternative method of `open` which call we wish to mock +patch = @patch open(fn::Function, f::AbstractString) = fn(IOBuffer(data)) + +# Apply the patch which will modify the behaviour for our test +apply(patch) do + @test randdev(n) == convert(Array{UInt8}, n:-1:1) +end + +# Outside of the scope of the patched environment `@mock` is essentially a no-op +@test randdev(n) != convert(Array{UInt8}, n:-1:1) + +# output +Test Passed +``` diff --git a/src/dispatch.jl b/src/dispatch.jl index 61b59e8..23df303 100644 --- a/src/dispatch.jl +++ b/src/dispatch.jl @@ -11,15 +11,25 @@ type_morespecific(a, b) = ccall(:jl_type_morespecific, Bool, (Any, Any), a, b) Construct a Tuple of the methods signature with the function type removed # Example +```@meta +DocTestSetup = quote + using Mocking: anonymous_signature +end +``` + ```jldoctest julia> m = first(methods(first, (String,))); julia> m.sig -Tuple{typeof(first),Any} +Tuple{typeof(first), Any} julia> anonymous_signature(m) Tuple{Any} ``` + +```@meta +DocTestSetup = nothing +``` """ anonymous_signature(m::Method) = anonymous_signature(m.sig) anonymous_signature(sig::DataType) = Tuple{sig.parameters[2:end]...} diff --git a/src/mock.jl b/src/mock.jl index 0928ad2..f86ea5c 100644 --- a/src/mock.jl +++ b/src/mock.jl @@ -1,3 +1,27 @@ +""" + @mock expr + +Allows the call site function to be temporarily overloaded via an applied patch. + +The `@mock` macro works as no-op until `Mocking.activate` has been called. Once Mocking has +been activated then alternative methods defined via [`@patch`](@ref) can be used with +[`apply`](@ref) to call the patched methods from within the `apply` context. + +See also: [`@patch`](@ref), [`apply`](@ref). + +## Examples + +```jldoctest; setup=:(using Dates: Dates) +julia> f() = @mock time(); + +julia> p = @patch time() = 0.0; # UNIX epoch + +julia> apply(p) do + Dates.unix2datetime(f()) + end +1970-01-01T00:00:00 +``` +""" macro mock(expr) NULLIFIED[] && return esc(expr) # Convert `@mock` into a no-op for maximum performace diff --git a/src/patch.jl b/src/patch.jl index 04ed90f..be99872 100644 --- a/src/patch.jl +++ b/src/patch.jl @@ -4,6 +4,14 @@ struct Patch{T} alternate::Function end +""" + @patch expr + +Creates a patch from a function definition. A patch can be used with [`apply`](@ref) to +temporarily include the patch when performing multiple dispatch on `@mock`ed call sites. + +See also: [`@mock`](@ref), [`apply`](@ref). +""" macro patch(expr::Expr) def = splitdef(expr) @@ -77,6 +85,7 @@ end function apply!(pe::PatchEnv, p::Patch) alternate_funcs = get!(Vector{Function}, pe.mapping, p.target) + # isempty(alternate_funcs) && push!(alternate_funcs, p.target) push!(alternate_funcs, p.alternate) return pe end @@ -89,42 +98,104 @@ function apply!(pe::PatchEnv, patches) end """ - apply(body::Function, patches; debug::Bool=false) - apply(body::Function, pe::PatchEnv) + apply(body::Function, patches; debug::Bool=false) -> Any -Convenience function to run `body` in the context of the given `patches`. +Applies one or more `patches` during execution of `body`. Specifically ,any [`@mock`](@ref) +call sites encountered while running `body` will include the provided `patches` when +performing dispatch. -This is intended to be used with do-block notation, e.g.: +Multiple-dispatch is used to determine which method to call when utilizing multiple patches. +However, patch defined methods always take precedence over the original function methods. -``` -patch = @patch ... -apply(patch) do - ... -end +!!! note + Ensure you have called [`activate`](@ref) prior to calling `apply` as otherwise the + provided patches will be ignored. + +See also: [`@mock`](@ref), [`@patch`](@ref). + +## Examples + +Applying a patch allows the alternative patch function to be called: + +```jldoctest +julia> f() = "original"; + +julia> p = @patch f() = "patched"; + +julia> apply(p) do + @mock f() + end +"patched" ``` -## Nesting +Patches take precedence over the original function even when the original method is more +specific: -Note that calls to apply will nest the patches that are applied. If multiple patches -are made to the same method, the innermost patch takes precedence. +```jldoctest +julia> f(::Int) = "original"; -The following two examples are equivalent: +julia> p = @patch f(::Any) = "patched"; +julia> apply(p) do + @mock f(1) + end +"patched" ``` -patch_2 = @patch ... -apply([patch, patch_2]) do - ... -end + +However, when the patches do not provide a valid method to call then the original function +will be used as a fallback: + +```jldoctest +julia> f(::Int) = "original"; + +julia> p = @patch f(::Char) = "patched"; + +julia> apply(p) do + @mock f(1) + end +"original" ``` +### Nesting + +Nesting multiple [`apply`](@ref) calls is allowed. When multiple patches are provided for +the same method then the innermost patch takes precedence: + +```jldoctest +julia> f() = "original"; + +julia> p1 = @patch f() = "p1"; + +julia> p2 = @patch f() = "p2"; + +julia> apply(p1) do + apply(p2) do + @mock f() + end + end +"p2" ``` -apply(patch) do - apply(patch_2) do - ... - end -end + +When multiple patches are provided for different methods then multiple-dispatch is used to +select the most specific patch: + +```jldoctest +julia> f(::Int) = "original"; + +julia> p1 = @patch f(::Integer) = "p1"; + +julia> p2 = @patch f(::Number) = "p2"; + +julia> apply(p1) do + apply(p2) do + @mock f(1) + end + end +"p1" ``` """ +function apply end + function apply(body::Function, pe::PatchEnv) merged_pe = merge(PATCH_ENV[], pe) return with_active_env(body, merged_pe) diff --git a/test/readme.jl b/test/randdev.jl similarity index 92% rename from test/readme.jl rename to test/randdev.jl index c6ad21d..09b4b82 100644 --- a/test/readme.jl +++ b/test/randdev.jl @@ -1,5 +1,5 @@ -# Testcase from example given in Mocking.jl's README -@testset "readme" begin +# Testcase from example given in Mocking.jl's documentation +@testset "randdev" begin # Note: Function only works in UNIX environments. function randdev(n::Integer) @mock open("/dev/urandom") do fp diff --git a/test/runtests.jl b/test/runtests.jl index 99fa2f0..4afc913 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,8 +3,7 @@ using Test using Aqua: Aqua using Dates: Dates, Hour -using Mocking: apply -using Mocking: anon_morespecific, anonymous_signature, dispatch, type_morespecific +using Mocking: anon_morespecific, anonymous_signature, apply, dispatch, type_morespecific Mocking.activate() @@ -26,7 +25,7 @@ Mocking.activate() include("real-isfile.jl") include("real-nested.jl") include("mock-in-patch.jl") - include("readme.jl") + include("randdev.jl") include("optional.jl") include("patch-gen.jl") include("anonymous-param.jl")