1 /** 2 Property-based testing. 3 */ 4 module unit_threaded.property; 5 6 import unit_threaded.from; 7 8 /// 9 class PropertyException : Exception 10 { 11 this(in string msg, string file = __FILE__, 12 size_t line = __LINE__, Throwable next = null) @safe pure nothrow 13 { 14 super(msg, file, line, next); 15 } 16 } 17 18 /** 19 Check that bool-returning F is true with randomly generated values. 20 */ 21 void check(alias F, int numFuncCalls = 100) 22 (in uint seed = from!"std.random".unpredictableSeed, 23 in string file = __FILE__, 24 in size_t line = __LINE__) 25 { 26 import unit_threaded.randomized.random: RndValueGen; 27 import unit_threaded.exception: UnitTestException; 28 import std.conv: text; 29 import std.traits: ReturnType, Parameters, isSomeString, isSafe; 30 import std.array: join; 31 import std.typecons: Flag, Yes, No; 32 import std.random: Random; 33 34 static assert(is(ReturnType!F == bool), 35 text("check only accepts functions that return bool, not ", ReturnType!F.stringof)); 36 37 scope random = Random(seed); 38 39 // See https://github.com/atilaneves/unit-threaded/issues/187 for why 40 auto createGenerator(ref Random random) { 41 return RndValueGen!(Parameters!F)(&random); 42 } 43 44 // It might be that some projects don't use dip1000 and so 45 // createGenerator isn't safe 46 static if(isSafe!createGenerator) 47 scope gen = createGenerator(random); 48 else 49 scope gen = () @trusted { return createGenerator(random); }(); 50 51 auto input(Flag!"shrink" shrink = Yes.shrink) { 52 53 scope string[] ret; 54 55 static if(Parameters!F.length == 1 && canShrink!(Parameters!F[0])) { 56 auto val = gen.values[0].value; 57 auto shrunk = shrink ? val.shrink!F : val; 58 ret ~= shrunk.text.idup; 59 static if(isSomeString!(Parameters!F[0])) 60 ret[$-1] = `"` ~ ret[$-1] ~ `"`; 61 } else { 62 foreach(ref valueGen; gen.values) { 63 ret ~= valueGen.text; 64 } 65 } 66 67 return ret.join(", "); 68 } 69 70 foreach(i; 0 .. numFuncCalls) { 71 bool pass; 72 73 safelyCatchThrowable!( 74 { gen.genValues; }, 75 (Throwable t) @trusted { throw new PropertyException("Error generating values\n" ~ t.toString, file, line, t); } 76 ); 77 78 pass = safelyCatchThrowable!( 79 () => F(gen.values), 80 (Throwable t) @trusted { 81 throw new UnitTestException( 82 text("Property threw. Seed: ", seed, ". Input: ", input(No.shrink), ". Message: ", t.msg), 83 file, 84 line, 85 t, 86 ); 87 } 88 ); 89 90 if(!pass) 91 throw new UnitTestException(text("Property failed. Seed: ", seed, ". Input: ", input), file, line); 92 } 93 } 94 95 96 private auto safelyCatchThrowable(alias Func, alias Handler, A...)(auto ref A args) { 97 import std.traits: isSafe, ReturnType; 98 99 ReturnType!Func impl() { 100 try 101 return Func(args); 102 catch(Throwable t) 103 Handler(t); 104 assert(0); 105 } 106 107 static if(isSafe!Func && isSafe!Handler) 108 return () @trusted { return impl; }(); 109 else 110 return impl; 111 } 112 113 114 /** 115 For values that unit-threaded doesn't know how to generate, test that the Predicate 116 holds, using Generator to come up with new values. 117 */ 118 void checkCustom(alias Generator, alias Predicate) 119 (int numFuncCalls = 100, in string file = __FILE__, in size_t line = __LINE__) @trusted { 120 121 import unit_threaded.exception: UnitTestException; 122 import std.conv: text; 123 import std.traits: ReturnType; 124 125 static assert(is(ReturnType!Predicate == bool), 126 text("check only accepts functions that return bool, not ", ReturnType!F.stringof)); 127 128 alias Type = ReturnType!Generator; 129 130 foreach(i; 0 .. numFuncCalls) { 131 132 Type object; 133 134 try { 135 object = Generator(); 136 } catch(Throwable t) { 137 throw new PropertyException("Error generating value\n" ~ t.toString, file, line, t); 138 } 139 140 bool pass; 141 142 try { 143 pass = Predicate(object); 144 } catch(Throwable t) { 145 throw new UnitTestException( 146 text("Property threw. Input: ", object, ". Message: ", t.msg), 147 file, 148 line, 149 t, 150 ); 151 } 152 153 if(!pass) { 154 throw new UnitTestException("Property failed with input:" ~ object.text, file, line); 155 } 156 } 157 } 158 159 160 private auto shrinkOne(alias F, int index, T)(T values) { 161 import std.stdio; 162 import std.traits; 163 auto orig = values[index]; 164 return shrink!((a) { 165 values[index] = a; 166 return F(values.expand); 167 })(orig); 168 169 } 170 171 /// 172 @("Verify identity property for int[] succeeds") 173 @safe unittest { 174 175 int numCalls; 176 bool identity(int[] a) pure { 177 ++numCalls; 178 return a == a; 179 } 180 181 check!identity; 182 assert(numCalls == 100); 183 184 numCalls = 0; 185 check!(identity, 10); 186 assert(numCalls == 10); 187 } 188 189 /// 190 @("Explicit Gen") 191 @safe unittest { 192 import unit_threaded.randomized.gen; 193 import unit_threaded.exception: UnitTestException; 194 import std.exception: assertThrown; 195 196 check!((Gen!(int, 1, 1) a) => a == 1); 197 assertThrown!UnitTestException(check!((Gen!(int, 1, 1) a) => a == 2)); 198 } 199 200 private enum canShrink(T) = __traits(compiles, shrink!((T _) => true)(T.init)); 201 202 T shrink(alias F, T)(T value) { 203 import std.conv: text; 204 205 assert(!F(value), text("Property did not fail for value ", value)); 206 207 T[][] oldParams; 208 return shrinkImpl!F(value, [value], oldParams); 209 } 210 211 private T shrinkImpl(alias F, T)(in T value, T[] candidates, T[][] oldParams = []) 212 if(from!"std.traits".isIntegral!T) 213 { 214 import std.algorithm: canFind, minPos; 215 import std.traits: isSigned; 216 217 auto params = value ~ candidates; 218 if(oldParams.canFind(params)) return value; 219 oldParams ~= params; 220 221 // if it suddenly starts passing we've found our boundary value 222 if(value < T.max && F(value + 1)) return value; 223 if(value > T.min && F(value - 1)) return value; 224 225 bool stillFails(T attempt) { 226 if(!F(attempt) && !candidates.canFind(attempt)) { 227 candidates ~= attempt; 228 return true; 229 } 230 231 return false; 232 } 233 234 T[] attempts; 235 if(value != 0) { 236 static if(isSigned!T) attempts ~= -value; 237 attempts ~= value / 2; 238 } 239 if(value < T.max / 2) attempts ~= cast(T)(value * 2); 240 if(value < T.max) attempts ~= cast(T)(value + 1); 241 if(value > T.min) attempts ~= cast(T)(value - 1); 242 243 foreach(attempt; attempts) 244 if(stillFails(attempt)) 245 return shrinkImpl!F(attempt, candidates, oldParams); 246 247 const min = candidates.minPos[0]; 248 const max = candidates.minPos!"a > b"[0]; // maxPos doesn't exist before DMD 2.071.0 249 250 if(!F(min)) return shrinkImpl!F(min, candidates, oldParams); 251 if(!F(max)) return shrinkImpl!F(max, candidates, oldParams); 252 253 return candidates[0]; 254 } 255 256 static assert(canShrink!int); 257 258 259 private T shrinkImpl(alias F, T)(T value, T[] candidates, T[][] oldParams = []) 260 if(from!"std.traits".isArray!T) 261 { 262 if(value == []) return value; 263 264 if(value.length == 1) { 265 T empty; 266 return !F(empty) ? empty : value; 267 } 268 269 auto fst = value[0 .. $ / 2]; 270 auto snd = value[$ / 2 .. $]; 271 if(!F(fst)) return shrinkImpl!F(fst, candidates); 272 if(!F(snd)) return shrinkImpl!F(snd, candidates); 273 274 if(F(value[0 .. $ - 1])) return value[0 .. $ - 1]; 275 if(F(value[1 .. $])) return value[1 .. $]; 276 277 if(!F(value[0 .. $ - 1])) return shrinkImpl!F(value[0 .. $ - 1], candidates); 278 if(!F(value[1 .. $])) return shrinkImpl!F(value[1 .. $], candidates); 279 return candidates[0]; 280 }