Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using Cassette #60

Closed
wants to merge 54 commits into from
Closed
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
4ce63b9
Now uses Cassette [WIP]
oxinabox Nov 30, 2018
816c3bf
fix @mock noop
oxinabox Dec 3, 2018
75d4837
fix @mock noop maxlog
oxinabox Dec 3, 2018
516773c
Correct grammer on deprecations
oxinabox Dec 3, 2018
6b8693b
Correctly test core mocking behavour
oxinabox Dec 3, 2018
07c6c48
remove unused function in test
oxinabox Dec 4, 2018
d876c2d
organise tests of various closures
oxinabox Dec 4, 2018
a0c7e8c
test mocking various scopes
oxinabox Dec 4, 2018
7b7ffcb
Perform more tests
oxinabox Dec 4, 2018
c434bfd
each file is in its own testset
oxinabox Dec 4, 2018
510badf
does not print debug output during test
oxinabox Dec 4, 2018
51852ee
mocking some methods without others
oxinabox Dec 4, 2018
a8f18dd
clearout most of the Compat
oxinabox Dec 4, 2018
0940031
run patch-gen tests
oxinabox Dec 4, 2018
1ca2f99
run anonymous-param tests
oxinabox Dec 4, 2018
faafaad
clearout more of the Compat
oxinabox Dec 4, 2018
5fa8c82
give correct lines in deprecating `at-mock`
oxinabox Dec 5, 2018
50a8a7b
`at-mock` is not used
oxinabox Dec 5, 2018
d6804c0
organisation of files improved
oxinabox Dec 5, 2018
42239c8
bindings are fully localized relative to the module where at-patch is…
oxinabox Dec 5, 2018
6d11bef
not gather up modules
oxinabox Dec 5, 2018
e2c59b0
strip out most of the extra stuff for generating expressions for patches
oxinabox Dec 5, 2018
a8c1381
enable mocking methods with kwargs
oxinabox Dec 5, 2018
f1a14d2
test expression building
oxinabox Dec 5, 2018
c7151f0
fix #59
oxinabox Dec 6, 2018
b4b8b13
allow readme test to fail
oxinabox Dec 6, 2018
96fa725
fix changed signatures test
oxinabox Dec 6, 2018
600ae70
test patch signatures
oxinabox Dec 6, 2018
67e0cfc
test nested modules
oxinabox Dec 6, 2018
ed48b88
remove modules referenced relative to themselves e.g. Main.Main
oxinabox Dec 6, 2018
ec15a65
Core is no longer references relative to call location
oxinabox Dec 6, 2018
0af4b05
remove the debug statements
oxinabox Dec 6, 2018
14cf066
update readme
oxinabox Dec 6, 2018
b1b5f31
remove the reference to `at-mock` in the readme
oxinabox Dec 6, 2018
8566b82
complete docstring for localise
oxinabox Dec 7, 2018
efb5192
use only julia 1.0
oxinabox Dec 7, 2018
93e00ad
fix and test deprecations
oxinabox Dec 7, 2018
82f1efc
tweak gotchas
oxinabox Dec 11, 2018
9d8f9cf
tweak gotchas
oxinabox Dec 11, 2018
f00621b
improve deprecation message
oxinabox Dec 11, 2018
21d6855
fix comparason to nothing to use reference nonequality
oxinabox Dec 11, 2018
bdcaea2
tweak the readme
oxinabox Dec 11, 2018
c74c508
remove unused kwarg struct
oxinabox Dec 11, 2018
9c5d4a8
use US english
oxinabox Dec 11, 2018
69b4427
Merge branch 'ox/cassette' of https://github.com/invenia/Mocking.jl i…
oxinabox Dec 11, 2018
24f263e
remove unused imports
oxinabox Dec 11, 2018
ef2c6fd
do not escape unnesc
oxinabox Dec 12, 2018
cb0b3b2
update the deprecation tests wording
oxinabox Dec 12, 2018
66a977f
add docstring to `apply`
oxinabox Dec 12, 2018
a3a09a6
fix typo in test
oxinabox Dec 12, 2018
bad56fa
simplify PatchEnv
oxinabox Dec 12, 2018
81599d2
no more depwarn=error during tests as testing deprecations
oxinabox Dec 14, 2018
277ca87
Parameterize PatchEnv context via its metadata
oxinabox Dec 18, 2018
4315c1d
Move to Cassette 0.2
oxinabox Jan 22, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ os:
- linux
- osx
julia:
- 0.6
- 0.7
- 1.0
- nightly
env:
Expand All @@ -17,17 +15,16 @@ notifications:
script:
- if [[ -a .git/shallow ]]; then git fetch --unshallow; fi
- |
julia --depwarn=error -e '
VERSION >= v"0.7.0-DEV.3656" && using Pkg
if VERSION >= v"0.7.0-DEV.5183" && (isfile("Project.toml") || isfile("JuliaProject.toml"))
julia -e '
using Pkg
if isfile("Project.toml") || isfile("JuliaProject.toml")
Pkg.build(); Pkg.test(coverage=true)
else
Pkg.clone(pwd()); Pkg.build("Mocking"); Pkg.test("Mocking"; coverage=true)
end'
after_success:
- |
julia -e '
VERSION >= v"0.7.0-DEV.3656" && using Pkg
VERSION >= v"0.7.0-DEV.5183" || cd(Pkg.dir("Mocking"))
using Pkg
Pkg.add("Coverage"); using Coverage
Codecov.submit(process_folder())'
56 changes: 17 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,21 @@ result = randdev(n)
@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`:
How could we create a test that shows the output of the function is reversed?
Mocking.jl provides a mechanism which allows package developers to temporarily overload a
specific calls in their package. In this example we will mock the `open` call
in `randdev`.
No changes are required at the call site.

```julia
using Mocking

function randdev(n::Integer)
@mock open("/dev/urandom") do fp
reverse(read(fp, n))
end
end
```
oxinabox marked this conversation as resolved.
Show resolved Hide resolved

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:
We just need to write a testcase which allows
us to demonstrate the reversing behaviour within the `randdev` function.
This is done using the `@patch` macro, to define a patch,
which is applied to a block of code using the `apply` function.
As shown in the example below:

```julia
using Mocking
Mocking.enable() # Need to enable before we import any code using the `@mock` macro

using Base.Test
import ...: randdev
Expand All @@ -79,38 +73,22 @@ 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
# Outside of the scope of the patched environment behavour is
# as it was before
@test randdev(n) != convert(Array{UInt8}, n:-1:1)
```

Gotchas
-------

Remember to:

- use `@mock` at desired call sites
- start julia with `--compiled-modules=no` (`--compilecache=no` for ≤0.6) or pass `force=true` to `Mocking.enable`
- run `Mocking.enable` before importing the module(s) being tested

Notes
-----

Mocking.jl is intended to be used for testing only and will not affect the performance of
your code when using `@mock`. In fact the `@mock` is actually a no-op when `Mocking.enable`
is not called. One side effect of this behaviour is that pre-compiled packages won't test
correctly with Mocking unless you start Julia with `--compiled-modules=no` (≥0.7) or
`--compilecache=no` (≤0.6).

```
$ julia --compilecache=no -e Pkg.test("...")
```
- Remember to `using`/`import` functions before you `@patch` them.
- You can not mock a method that does not exist.

Alternatively you can use `Mocking.enable(force=true)` to automatically disable using
package precompilation for you (experimental). Make sure to call `enable` before the you
importing the module you are testing.
Mocking.jl relies heavily on [Cassette.jl](https://github.com/jrevels/Cassette.jl).
Many issues that affect Cassette.jl will also affect Mocking.jl.


License
-------

Mocking.jl is provided under the [MIT "Expat" License](LICENSE.md).
Mocking.jl is provided under the [MIT "Expat" License](LICENSE.md).
5 changes: 3 additions & 2 deletions REQUIRE
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
julia 0.6
Compat 0.59
julia 1.0
MacroTools
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do this without MacroTools? It's only used in a couple of places for checking input forms, which can be done by hand.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to solve #61
using MacroTools is probably going to be the best way.
I think it would be better to go the otehr way and remove more of the hand checking code and rewrite it to be more readable using MacroTools.

Cassette
2 changes: 0 additions & 2 deletions appveyor.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
environment:
matrix:
- julia_version: 0.6
- julia_version: 0.7
- julia_version: 1.0
- julia_version: latest

Expand Down
242 changes: 12 additions & 230 deletions src/Mocking.jl
Original file line number Diff line number Diff line change
@@ -1,247 +1,29 @@
__precompile__(true)

module Mocking
using MacroTools
using Cassette
using Cassette: @context

using Compat: @__MODULE__, hasmethod, invokelatest, undef, @info, @warn

include("expr.jl")
include("bindings.jl")
include("options.jl")
include("deprecated.jl")
include("patch.jl")
include("patchenv.jl")


export
# Mocking.jl
@patch, @mock, Patch, apply,
# options.jl
DISABLE_COMPILED_MODULES_STR, DISABLE_COMPILED_MODULES_CMD

# When ENABLED is false the @mock macro is a noop.
global ENABLED = false
global PATCH_ENV = nothing

function enable(; force::Bool=false)
ENABLED::Bool && return # Abend early if enabled has already been set
global ENABLED = true
global PATCH_ENV = PatchEnv()

if compiled_modules_enabled()
if force
# Disable using compiled modules when Mocking is enabled
set_compiled_modules(false)
else
@warn(
"Mocking.jl will probably not work when $COMPILED_MODULES_FLAG is ",
"enabled. Please start `julia` with `$DISABLE_COMPILED_MODULES_STR` ",
"or alternatively call `Mocking.enable(force=true).`",
)
end
end
end

struct Patch
signature::Expr
body::Function
modules::Set
# translation::Dict

function Patch(signature::Expr, body::Function, translation::Dict)
trans = adjust_bindings(translation)
sig = name_parameters(absolute_signature(signature, trans))

# On VERSION >= v"0.5"
# modules = Set(b.args[1] for b in values(trans) if isa(b, Expr))
modules = Set()
for b in values(trans)
if isa(b, Expr)
push!(modules, b.args[1])
end
end

new(sig, body, modules)
end
end

# TODO: Find non-eval way to determine module locations of Types
# evaling in the @patch scope seems to be problematic for pre-compliation
# first(methods(x)).sig.types[2:end]

# We can use the @patch macro to create a list of bindings used then pass that
# in as an array into Patch. At runtime the types and function names will be fully
# qualified

# We can support optional parameters and keywords by using generic functions on
# 0.4

function convert(::Type{Expr}, p::Patch)
exprs = Expr[]
@patch, Patch, apply,
# deprecated.jl
@mock

# Generate imports for all required modules
for m in p.modules
bindings = splitbinding(m)

:Main in bindings && error("Mocking cannot handle bindings from Main.")

for i in 1:length(bindings)
import_expr = if VERSION > v"0.7.0-DEV.3187"
Expr(:import, Expr(:., bindings[1:i]...))
else
Expr(:import, bindings[1:i]...)
end
push!(exprs, import_expr)
end
end

# Generate the new method which will call the user's patch function. We need to perform
# this call instead of injecting the body expression to support closures.
sig, body = p.signature, p.body
params = call_parameters(sig)
push!(exprs, Expr(:(=), sig, Expr(:block, Expr(:call, body, params...))))

return Expr(:block, exprs...)
end

macro patch(expr::Expr)
if expr.head == :function
name = expr.args[1].args[1]
params = expr.args[1].args[2:end]
body = expr.args[2]

# Short-form function syntax
elseif expr.head == :(=) && expr.args[1].head == :call
name = expr.args[1].args[1]
params = expr.args[1].args[2:end]
body = expr.args[2]

# Anonymous function syntax
# elseif expr.head == :(->)
# TODO: Determine how this could be supported
else
throw(ArgumentError("expression is not a function definition"))
end

signature = Expr(:call, name, params...)

# Determine the bindings used in the signature
bindings = Bindings(signature)

# Need to evaluate the body of the function in the context of the `@patch` macro in
# order to support closures.
# func = Expr(:(->), Expr(:tuple, params...), body)
func = Expr(:(=), Expr(:call, gensym(), params...), body)

# Generate a translation between the external bindings and the runtime types and
# functions. The translation will be used to revise all bindings to be absolute.
translations = [Expr(:call, :(=>), QuoteNode(b), b) for b in bindings.external]

return esc(:(Mocking.Patch( $(QuoteNode(signature)), $func, Dict($(translations...)) )))
end

struct PatchEnv
mod::Module
debug::Bool

function PatchEnv(debug::Bool=false)
# Be careful not to call this code during pre-compilation otherwise we'll see the
# warning: "incremental compilation may be broken for this module"
m = Core.eval(Mocking, :(module $(gensym()) end))
new(m, debug)
end
end

function PatchEnv(patches::Array{Patch}, debug::Bool=false)
pe = PatchEnv(debug)
apply!(pe, patches)
return pe
end

function PatchEnv(patch::Patch, debug::Bool=false)
pe = PatchEnv(debug)
apply!(pe, patch)
return pe
end

function apply!(pe::PatchEnv, p::Patch)
Core.eval(pe.mod, convert(Expr, p))
end

function apply!(pe::PatchEnv, patches::Array{Patch})
for p in patches
apply!(pe, p)
end
end

function apply(body::Function, pe::PatchEnv)
original_pe = get_active_env()
set_active_env(pe)
try
return body()
finally
set_active_env(original_pe)
end
end

function apply(body::Function, patches::Array{Patch}; debug::Bool=false)
apply(body, PatchEnv(patches, debug))
end
function apply(body::Function, patch::Patch; debug::Bool=false)
apply(body, PatchEnv(patch, debug))
end

function ismocked(pe::PatchEnv, func_name::Symbol, args::Tuple)
if isdefined(pe.mod, func_name)
func = Core.eval(pe.mod, func_name)
types = map(arg -> isa(arg, Type) ? Type{arg} : typeof(arg), args)
exists = hasmethod(func, types)

if pe.debug
@info("calling $func_name$(types)")
if exists
m = first(methods(func, types))
@info("executing mocked function: $m")
else
m = first(methods(Core.eval(func_name), types))
@info("executing original function: $m")
end
end

return exists
end
return false
# TODO: redefine this in terms of `methodswith(pe.ctx, Cassette.execute...)`
# If required
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@omus is this method required?
Should I delete it,
or fix it?

error("`ismocked` is not implemented")
end

set_active_env(pe::PatchEnv) = (global PATCH_ENV = pe)
get_active_env() = PATCH_ENV::PatchEnv

macro mock(expr)
isa(expr, Expr) || error("argument is not an expression")
expr.head == :do && (expr = rewrite_do(expr))
expr.head == :call || error("expression is not a function call")
ENABLED::Bool || return esc(expr) # @mock is a no-op when Mocking is not ENABLED

func = expr.args[1]
func_name = QuoteNode(func)
args = filter(x -> !Mocking.iskwarg(x), expr.args[2:end])
kwargs = extract_kwargs(expr)

env_var = gensym("env")
args_var = gensym("args")

# Note: The fix to Julia issue #265 (PR #17057) introduced changes where no compiled
# calls could be made to functions compiled afterwards. Since the `Mocking.apply`
# do-block syntax compiles the body of the do-block function before evaluating the
# "outer" function this means our patch functions will be compiled after the "inner"
# function.
result = quote
local $env_var = Mocking.get_active_env()
local $args_var = tuple($(args...))
if Mocking.ismocked($env_var, $func_name, $args_var)
Mocking.invokelatest($env_var.mod.$func, $args_var...; $(kwargs...))
else
$func($args_var...; $(kwargs...))
end
end

return esc(result)
end

end # module
Loading